mail

package
v1.0.21 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 28, 2026 License: MIT Imports: 39 Imported by: 0

Documentation

Index

Constants

View Source
const (
	// MaxAttachmentCount is the maximum number of attachments (including original
	// attachments carried over in +forward) allowed per message.
	MaxAttachmentCount = 250

	// MaxAttachmentBytes is the maximum combined size of all attachments in bytes.
	// Note: the overall EML size limit (emlbuilder.MaxEMLSize) is enforced separately.
	MaxAttachmentBytes = 25 * 1024 * 1024 // 25 MB

	// MaxAttachmentDownloadBytes is the safety limit for downloading a single
	// attachment. This is larger than MaxAttachmentBytes (which governs outgoing
	// composition) to allow for received attachments that exceed the send-side
	// limit. The purpose is to prevent unbounded memory allocation.
	MaxAttachmentDownloadBytes = 35 * 1024 * 1024 // 35 MB

	// MaxRecipientCount is the maximum total number of recipients (To + CC + BCC
	// combined) allowed per message. This is a defence-in-depth measure to prevent
	// abuse such as mass spam or mail bombing via the CLI.
	MaxRecipientCount = 500
)

Mail composition limits enforced before sending.

View Source
const MaxLargeAttachmentSize = 3 * 1024 * 1024 * 1024 // 3 GB

MaxLargeAttachmentSize is the maximum allowed size for a single large attachment, aligned with the desktop client (3 GB).

Variables

View Source
var MailDeclineReceipt = common.Shortcut{
	Service:     "mail",
	Command:     "+decline-receipt",
	Description: "Dismiss the read-receipt request banner on an incoming mail by clearing its READ_RECEIPT_REQUEST label, without sending a receipt. Use when the user wants to silence the prompt but refuse to confirm they have read it. Idempotent — safe to re-run.",
	Risk:        "write",
	Scopes: []string{
		"mail:user_mailbox.message:modify",
		"mail:user_mailbox.message:readonly",
		"mail:user_mailbox:readonly",

		"mail:user_mailbox.message.body:read",
	},
	AuthTypes: []string{"user"},
	Flags: []common.Flag{
		{Name: "message-id", Desc: "Required. Message ID of the incoming mail that requested a read receipt.", Required: true},
		{Name: "mailbox", Desc: "Mailbox email address that owns the message (default: me)."},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		messageID := runtime.Str("message-id")
		mailboxID := resolveComposeMailboxID(runtime)
		return common.NewDryRunAPI().
			Desc("Decline read receipt: fetch the original message → verify the READ_RECEIPT_REQUEST label is present → PUT user_mailbox.message.modify (the OpenAPI wrapper around the MessageModify RPC the Lark client's \"不发送\" button triggers) with remove_label_ids=[\"READ_RECEIPT_REQUEST\"]. No outgoing mail is produced; the banner is cleared locally. Idempotent when the label is already absent.").
			GET(mailboxPath(mailboxID, "messages", messageID)).
			Params(map[string]interface{}{"format": messageGetFormat(false)}).
			PUT(mailboxPath(mailboxID, "messages", messageID, "modify")).
			Body(map[string]interface{}{
				"remove_label_ids": []string{readReceiptRequestLabel},
			})
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		messageID := runtime.Str("message-id")
		mailboxID := resolveComposeMailboxID(runtime)

		msg, err := fetchFullMessage(runtime, mailboxID, messageID, false)
		if err != nil {
			return fmt.Errorf("failed to fetch original message: %w", err)
		}

		out := map[string]interface{}{
			"message_id":             messageID,
			"decline_receipt_for_id": messageID,
		}

		if !hasReadReceiptRequestLabel(msg) {
			out["declined"] = false
			out["already_cleared"] = true
			runtime.OutFormat(out, nil, func(w io.Writer) {
				fmt.Fprintln(w, "Read-receipt request already cleared — nothing to do.")
				fmt.Fprintf(w, "message_id: %s\n", messageID)
			})
			return nil
		}

		if _, err := runtime.CallAPI("PUT",
			mailboxPath(mailboxID, "messages", messageID, "modify"),
			nil,
			map[string]interface{}{
				"remove_label_ids": []string{readReceiptRequestLabel},
			},
		); err != nil {
			return fmt.Errorf("failed to clear READ_RECEIPT_REQUEST label: %w", err)
		}

		out["declined"] = true
		runtime.OutFormat(out, nil, func(w io.Writer) {
			fmt.Fprintln(w, "已关闭已读回执请求(未发送回执)/ Read-receipt request dismissed (no receipt sent).")
			fmt.Fprintf(w, "message_id: %s\n", messageID)
		})
		return nil
	},
}

MailDeclineReceipt is the `+decline-receipt` shortcut: dismiss the read- receipt request banner on an incoming message WITHOUT sending a receipt. Mirrors the Lark client's "不发送" button next to the read-receipt prompt — the client talks to the internal MessageModify RPC with RemoveLabelIds= ["-607"]; this shortcut calls the public OpenAPI user_mailbox.message.modify which accepts the symbolic "READ_RECEIPT_REQUEST" label name (the public endpoint performs the symbolic→numeric translation server-side). Either path lands on the same MessageModify codepath, closing the banner. Removes only the READ_RECEIPT_REQUEST system label; no outgoing mail is produced. Idempotent: running it on a message that no longer carries the label is a no-op, not an error.

View Source
var MailDraftCreate = common.Shortcut{
	Service:     "mail",
	Command:     "+draft-create",
	Description: "Create a brand-new mail draft from scratch (NOT for reply or forward). For reply drafts use +reply; for forward drafts use +forward. Only use +draft-create when composing a new email with no parent message.",
	Risk:        "write",
	Scopes:      []string{"mail:user_mailbox.message:modify", "mail:user_mailbox:readonly"},
	AuthTypes:   []string{"user"},
	HasFormat:   true,
	Flags: []common.Flag{
		{Name: "to", Desc: "Optional. Full To recipient list. Separate multiple addresses with commas. Display-name format is supported. When omitted, the draft is created without recipients (they can be added later via +draft-edit)."},
		{Name: "subject", Desc: "Final draft subject. Pass the full subject you want to appear in the draft. Required unless --template-id supplies a non-empty subject."},
		{Name: "body", Desc: "Full email body. Prefer HTML for rich formatting (bold, lists, links); plain text is also supported. Body type is auto-detected. Use --plain-text to force plain-text mode. Required unless --template-id supplies a non-empty body."},
		{Name: "from", Desc: "Optional. Sender email address for the From header. When using an alias (send_as) address, set this to the alias and use --mailbox for the owning mailbox. If omitted, the mailbox's primary address is used."},
		{Name: "mailbox", Desc: "Optional. Mailbox email address that owns the draft (default: falls back to --from, then me). Use this when the sender (--from) differs from the mailbox, e.g. sending via an alias or send_as address."},
		{Name: "cc", Desc: "Optional. Full Cc recipient list. Separate multiple addresses with commas. Display-name format is supported."},
		{Name: "bcc", Desc: "Optional. Full Bcc recipient list. Separate multiple addresses with commas. Display-name format is supported."},
		{Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring HTML auto-detection. Cannot be used with --inline."},
		{Name: "attach", Desc: "Optional. Regular attachment file paths (relative path only). Separate multiple paths with commas. Each path must point to a readable local file."},
		{Name: "inline", Desc: "Optional. Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
		{Name: "request-receipt", Type: "bool", Desc: "Request a read receipt (Message Disposition Notification, RFC 3798) addressed to the sender. Recipient mail clients may prompt the user, send automatically, or silently ignore — delivery of a receipt is not guaranteed."},
		{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's subject/body/to/cc/bcc/attachments are merged with user-supplied flags (user flags win). Requires --as user."},
		signatureFlag,
		priorityFlag,
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		mailboxID := resolveComposeMailboxID(runtime)
		api := common.NewDryRunAPI().
			Desc("Create a new empty draft without sending it. The command resolves the sender address (from --from, --mailbox, or mailbox profile), builds a complete EML from `to/subject/body` plus any optional cc/bcc/attachment/inline inputs, and finally calls drafts.create. `--body` content type is auto-detected (HTML or plain text); use `--plain-text` to force plain-text mode. For inline images, CIDs can be any unique strings, e.g. random hex. Use the dedicated reply or forward shortcuts for reply-style drafts instead of adding reply-thread headers here.")
		if tid := runtime.Str("template-id"); tid != "" {
			api = api.GET(templateMailboxPath(mailboxID, tid)).
				Desc("Fetch template to merge with compose flags (subject/body/to/cc/bcc/attachments).")
		}
		api = api.GET(mailboxPath(mailboxID, "profile")).
			POST(mailboxPath(mailboxID, "drafts")).
			Body(map[string]interface{}{
				"raw": "<base64url-EML>",
				"_preview": map[string]interface{}{
					"to":      runtime.Str("to"),
					"subject": runtime.Str("subject"),
				},
			})
		return api
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		if err := validateTemplateID(runtime.Str("template-id")); err != nil {
			return err
		}
		hasTemplate := runtime.Str("template-id") != ""
		if !hasTemplate && strings.TrimSpace(runtime.Str("subject")) == "" {
			return output.ErrValidation("--subject is required; pass the final email subject (or use --template-id)")
		}
		if !hasTemplate && strings.TrimSpace(runtime.Str("body")) == "" {
			return output.ErrValidation("--body is required; pass the full email body (or use --template-id)")
		}
		if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
			return err
		}
		if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")); err != nil {
			return err
		}
		return validatePriorityFlag(runtime)
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		priority, err := parsePriority(runtime.Str("priority"))
		if err != nil {
			return err
		}
		mailboxID := resolveComposeMailboxID(runtime)
		input := draftCreateInput{
			To:        runtime.Str("to"),
			Subject:   runtime.Str("subject"),
			Body:      runtime.Str("body"),
			From:      runtime.Str("from"),
			CC:        runtime.Str("cc"),
			BCC:       runtime.Str("bcc"),
			Attach:    runtime.Str("attach"),
			Inline:    runtime.Str("inline"),
			PlainText: runtime.Bool("plain-text"),
		}
		var templateLargeAttachmentIDs []string
		var templateInlineAttachments []templateInlineRef
		var templateSmallAttachments []templateAttachmentRef
		templateID := runtime.Str("template-id")
		if tid := templateID; tid != "" {
			tpl, err := fetchTemplate(runtime, mailboxID, tid)
			if err != nil {
				return err
			}
			merged := applyTemplate(
				templateShortcutDraftCreate, tpl,
				"", "", "", "", "",
				input.To, input.CC, input.BCC, input.Subject, input.Body,
			)
			input.To = merged.To
			input.CC = merged.Cc
			input.BCC = merged.Bcc
			input.Subject = merged.Subject
			input.Body = merged.Body
			if !input.PlainText && merged.IsPlainTextMode {
				input.PlainText = true
			}
			templateLargeAttachmentIDs = merged.LargeAttachmentIDs
			templateInlineAttachments = merged.InlineAttachments
			templateSmallAttachments = merged.SmallAttachments
			for _, w := range merged.Warnings {
				fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w)
			}
			inlineCount, largeCount := countAttachmentsByType(tpl.Attachments)
			logTemplateInfo(runtime, "apply.draft_create", map[string]interface{}{
				"mailbox_id":         mailboxID,
				"template_id":        tid,
				"is_plain_text_mode": input.PlainText,
				"attachments_total":  len(tpl.Attachments),
				"inline_count":       inlineCount,
				"large_count":        largeCount,
				"tos_count":          countAddresses(input.To),
				"ccs_count":          countAddresses(input.CC),
				"bccs_count":         countAddresses(input.BCC),
			})
		}
		if strings.TrimSpace(input.Subject) == "" {
			return output.ErrValidation("effective subject is empty after applying template; pass --subject explicitly")
		}
		if strings.TrimSpace(input.Body) == "" {
			return output.ErrValidation("effective body is empty after applying template; pass --body explicitly")
		}
		sigResult, err := resolveSignature(ctx, runtime, mailboxID, runtime.Str("signature-id"), runtime.Str("from"))
		if err != nil {
			return err
		}
		rawEML, err := buildRawEMLForDraftCreate(ctx, runtime, input, sigResult, priority,
			templateLargeAttachmentIDs, mailboxID, templateID, templateInlineAttachments, templateSmallAttachments)
		if err != nil {
			return err
		}
		draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
		if err != nil {
			return fmt.Errorf("create draft failed: %w", err)
		}
		out := map[string]interface{}{"draft_id": draftResult.DraftID}
		if draftResult.Reference != "" {
			out["reference"] = draftResult.Reference
		}
		runtime.OutFormat(out, nil, func(w io.Writer) {
			fmt.Fprintln(w, "Draft created.")

			fmt.Fprintf(w, "draft_id: %s\n", draftResult.DraftID)
			if reference, _ := out["reference"].(string); reference != "" {
				fmt.Fprintf(w, "reference: %s\n", reference)
			}
		})
		return nil
	},
}

MailDraftCreate is the `+draft-create` shortcut: create a brand-new mail draft from scratch. For reply drafts use +reply; for forward drafts use +forward.

View Source
var MailDraftEdit = common.Shortcut{
	Service:     "mail",
	Command:     "+draft-edit",
	Description: "Use when updating an existing mail draft without sending it. Prefer this shortcut over calling raw drafts.get or drafts.update directly, because it performs draft-safe MIME read/patch/write editing while preserving unchanged structure, attachments, and headers where possible.",
	Risk:        "write",
	Scopes:      []string{"mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly"},
	AuthTypes:   []string{"user"},
	HasFormat:   true,
	Flags: []common.Flag{
		{Name: "from", Default: "me", Desc: "Mailbox email address containing the draft (default: me). Prefer --mailbox for clarity; --from is kept for backward compatibility."},
		{Name: "mailbox", Desc: "Mailbox email address that owns the draft (default: falls back to --from, then me). Takes priority over --from when both are set."},
		{Name: "draft-id", Desc: "Target draft ID. Required for real edits. It can be omitted only when using the --print-patch-template flag by itself."},
		{Name: "set-subject", Desc: "Replace the subject with this final value. Use this for full subject replacement, not for appending a fragment to the existing subject."},
		{Name: "set-to", Desc: "Replace the entire To recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."},
		{Name: "set-cc", Desc: "Replace the entire Cc recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."},
		{Name: "set-bcc", Desc: "Replace the entire Bcc recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."},
		{Name: "patch-file", Desc: "Edit entry point for body edits, incremental recipient changes, header edits, attachment changes, or inline-image changes. All body edits MUST go through --patch-file. Two body ops: set_body (full replacement including quote) and set_reply_body (replaces only user-authored content, auto-preserves quote block). Run --inspect first to check has_quoted_content, then --print-patch-template for the JSON structure. Relative path only."},
		{Name: "print-patch-template", Type: "bool", Desc: "Print the JSON template and supported operations for the --patch-file flag. Recommended first step before generating a patch file. No draft read or write is performed."},
		{Name: "set-priority", Desc: "Set email priority: high, normal, low. Setting 'normal' removes any existing priority header."},
		{Name: "inspect", Type: "bool", Desc: "Inspect the draft without modifying it. Returns the draft projection including subject, recipients, body summary, has_quoted_content (whether the draft contains a reply/forward quote block), attachments_summary (with part_id and cid for each attachment), and inline_summary. Run this BEFORE editing body to check has_quoted_content: if true, use set_reply_body in --patch-file to preserve the quote; if false, use set_body."},
		{Name: "request-receipt", Type: "bool", Desc: "Request a read receipt (Message Disposition Notification, RFC 3798) addressed to the draft's sender. Recipient mail clients may prompt the user, send automatically, or silently ignore — delivery of a receipt is not guaranteed. Adds the Disposition-Notification-To header; existing value is overwritten."},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		if runtime.Bool("print-patch-template") {
			return common.NewDryRunAPI().
				Set("mode", "print-patch-template").
				Set("template", buildDraftEditPatchTemplate())
		}
		draftID := runtime.Str("draft-id")
		if draftID == "" {
			return common.NewDryRunAPI().Set("error", "--draft-id is required for real draft edits; only --print-patch-template can be used without a draft id")
		}
		mailboxID := resolveComposeMailboxID(runtime)
		if runtime.Bool("inspect") {
			return common.NewDryRunAPI().
				Desc("Inspect a draft without modifying it: fetch the raw EML, parse it into MIME structure, and return the projection (subject, recipients, body, attachments_summary, inline_summary). No write is performed.").
				GET(mailboxPath(mailboxID, "drafts", draftID)).
				Params(map[string]interface{}{"format": "raw"})
		}
		patch, err := buildDraftEditPatch(runtime)
		if err != nil {
			return common.NewDryRunAPI().Set("error", err.Error())
		}
		return common.NewDryRunAPI().
			Desc("Edit an existing draft without sending it: first call drafts.get(format=raw) to fetch the current EML, parse it into MIME structure, apply either direct flags or the typed patch from patch-file, re-serialize the updated draft, and then call drafts.update. This is a minimal-edit pipeline rather than a full rebuild, so unchanged headers, attachments, and MIME subtrees are preserved where possible. Body edits must go through --patch-file using set_body or set_reply_body ops. It also has no optimistic locking, so concurrent edits to the same draft are last-write-wins.").
			GET(mailboxPath(mailboxID, "drafts", draftID)).
			Params(map[string]interface{}{"format": "raw"}).
			PUT(mailboxPath(mailboxID, "drafts", draftID)).
			Body(map[string]interface{}{
				"raw":     "<base64url-EML>",
				"_patch":  patch.Summary(),
				"_notice": "This edit flow has no optimistic locking. If the same draft is changed concurrently, the last writer wins.",
			})
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		if runtime.Bool("print-patch-template") {
			runtime.Out(buildDraftEditPatchTemplate(), nil)
			return nil
		}
		draftID := runtime.Str("draft-id")
		if draftID == "" {
			return output.ErrValidation("--draft-id is required for real draft edits; if you only need a patch template, run with --print-patch-template")
		}
		mailboxID := resolveComposeMailboxID(runtime)
		if runtime.Bool("inspect") {
			return executeDraftInspect(runtime, mailboxID, draftID)
		}
		patch, err := buildDraftEditPatch(runtime)
		if err != nil {
			return err
		}
		rawDraft, err := draftpkg.GetRaw(runtime, mailboxID, draftID)
		if err != nil {
			return fmt.Errorf("read draft raw EML failed: %w", err)
		}
		snapshot, err := draftpkg.Parse(rawDraft)
		if err != nil {
			return output.ErrValidation("parse draft raw EML failed: %v", err)
		}
		// Pre-process insert_signature ops: resolve signature using the draft's
		// From address so alias/shared-mailbox senders get correct template vars.
		var draftFromEmail string
		if len(snapshot.From) > 0 {
			draftFromEmail = snapshot.From[0].Address
		}
		if err := requireSenderForRequestReceipt(runtime, draftFromEmail); err != nil {
			return err
		}
		if runtime.Bool("request-receipt") {

			if err := validateHeaderAddress(draftFromEmail); err != nil {
				return output.ErrValidation(
					"cannot set --request-receipt: draft From address is unsafe for a header (%v)", err)
			}
			patch.Ops = append(patch.Ops, draftpkg.PatchOp{
				Op:    "set_header",
				Name:  "Disposition-Notification-To",
				Value: "<" + draftFromEmail + ">",
			})
		}
		for i := range patch.Ops {
			if patch.Ops[i].Op == "insert_signature" {
				sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, patch.Ops[i].SignatureID, draftFromEmail)
				if sigErr != nil {
					return sigErr
				}
				if sigResult != nil {
					patch.Ops[i].RenderedSignatureHTML = sigResult.RenderedContent
					patch.Ops[i].SignatureImages = sigResult.Images
				}
			}
		}

		patch, err = preprocessLargeAttachmentsForDraftEdit(ctx, runtime, snapshot, patch)
		if err != nil {
			return err
		}
		dctx := &draftpkg.DraftCtx{FIO: runtime.FileIO()}
		if len(patch.Ops) > 0 {
			if err := draftpkg.Apply(dctx, snapshot, patch); err != nil {
				return output.ErrValidation("apply draft patch failed: %v", err)
			}
		}
		serialized, err := draftpkg.Serialize(snapshot)
		if err != nil {
			return output.ErrValidation("serialize draft failed: %v", err)
		}
		updateResult, err := draftpkg.UpdateWithRaw(runtime, mailboxID, draftID, serialized)
		if err != nil {
			return fmt.Errorf("update draft failed: %w", err)
		}
		projection := draftpkg.Project(snapshot)
		out := map[string]interface{}{
			"draft_id":   updateResult.DraftID,
			"warning":    "This edit flow has no optimistic locking. If the same draft is changed concurrently, the last writer wins.",
			"projection": projection,
		}
		if updateResult.Reference != "" {
			out["reference"] = updateResult.Reference
		}
		runtime.OutFormat(out, nil, func(w io.Writer) {
			fmt.Fprintln(w, "Draft updated.")
			fmt.Fprintf(w, "draft_id: %s\n", updateResult.DraftID)
			if reference, _ := out["reference"].(string); reference != "" {
				fmt.Fprintf(w, "reference: %s\n", reference)
			}
			if projection.Subject != "" {
				fmt.Fprintf(w, "subject: %s\n", sanitizeForTerminal(projection.Subject))
			}
			if recipients := prettyDraftAddresses(projection.To); recipients != "" {
				fmt.Fprintf(w, "to: %s\n", sanitizeForTerminal(recipients))
			}
			if projection.BodyText != "" {
				fmt.Fprintf(w, "body_text: %s\n", sanitizeForTerminal(projection.BodyText))
			}
			if projection.BodyHTMLSummary != "" {
				fmt.Fprintf(w, "body_html_summary: %s\n", sanitizeForTerminal(projection.BodyHTMLSummary))
			}
			if len(projection.AttachmentsSummary) > 0 {
				fmt.Fprintf(w, "attachments: %d\n", len(projection.AttachmentsSummary))
			}
			if len(projection.InlineSummary) > 0 {
				fmt.Fprintf(w, "inline_parts: %d\n", len(projection.InlineSummary))
			}
			if len(projection.Warnings) > 0 {
				fmt.Fprintf(w, "warnings: %s\n", sanitizeForTerminal(strings.Join(projection.Warnings, "; ")))
			}
			fmt.Fprintln(w, "warning: This edit flow has no optimistic locking. If the same draft is changed concurrently, the last writer wins.")
		})
		return nil
	},
}

MailDraftEdit is the `+draft-edit` shortcut: update an existing draft without sending it. Performs MIME-safe read/patch/write so unchanged structure, attachments, and headers are preserved where possible.

View Source
var MailForward = common.Shortcut{
	Service:     "mail",
	Command:     "+forward",
	Description: "Forward a message and save as draft (default). Use --confirm-send to send immediately after user confirmation. Original message block included automatically.",
	Risk:        "write",
	Scopes:      []string{"mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
	AuthTypes:   []string{"user"},
	Flags: []common.Flag{
		{Name: "message-id", Desc: "Required. Message ID to forward", Required: true},
		{Name: "to", Desc: "Recipient email address(es), comma-separated"},
		{Name: "body", Desc: "Body prepended before the forwarded message. Prefer HTML for rich formatting; plain text is also supported. Body type is auto-detected from the forward body and the original message. Use --plain-text to force plain-text mode."},
		{Name: "from", Desc: "Sender email address for the From header. When using an alias (send_as) address, set this to the alias and use --mailbox for the owning mailbox. Defaults to the mailbox's primary address."},
		{Name: "mailbox", Desc: "Mailbox email address that owns the draft (default: falls back to --from, then me). Use this when the sender (--from) differs from the mailbox, e.g. sending via an alias or send_as address."},
		{Name: "cc", Desc: "CC email address(es), comma-separated"},
		{Name: "bcc", Desc: "BCC email address(es), comma-separated"},
		{Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring all HTML auto-detection. Cannot be used with --inline."},
		{Name: "attach", Desc: "Attachment file path(s), comma-separated, appended after original attachments (relative path only)"},
		{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
		{Name: "confirm-send", Type: "bool", Desc: "Send the forward immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
		{Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."},
		{Name: "request-receipt", Type: "bool", Desc: "Request a read receipt (Message Disposition Notification, RFC 3798) addressed to the sender. Recipient mail clients may prompt the user, send automatically, or silently ignore — delivery of a receipt is not guaranteed."},
		{Name: "subject", Desc: "Optional. Override the auto-generated Fw: subject. When set, the shortcut uses this value verbatim instead of prefixing the original subject."},
		{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's body/to/cc/bcc/attachments are merged into the forward draft (template values appended to user flags / forward-derived values; no de-duplication)."},
		signatureFlag,
		priorityFlag},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		messageId := runtime.Str("message-id")
		to := runtime.Str("to")
		confirmSend := runtime.Bool("confirm-send")
		mailboxID := resolveComposeMailboxID(runtime)
		desc := "Forward: fetch original message → resolve sender address → save as draft"
		if confirmSend {
			desc = "Forward (--confirm-send): fetch original message → resolve sender address → create draft → send draft"
		}
		api := common.NewDryRunAPI().Desc(desc)
		if tid := runtime.Str("template-id"); tid != "" {
			api = api.GET(templateMailboxPath(mailboxID, tid)).
				Desc("Fetch template to merge with forward compose flags.")
		}
		api = api.GET(mailboxPath(mailboxID, "messages", messageId)).
			GET(mailboxPath(mailboxID, "profile")).
			POST(mailboxPath(mailboxID, "drafts")).
			Body(map[string]interface{}{"raw": "<base64url-EML>", "_to": to})
		if confirmSend {
			api = api.POST(mailboxPath(mailboxID, "drafts", "<draft_id>", "send"))
		}
		return api
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		if err := validateTemplateID(runtime.Str("template-id")); err != nil {
			return err
		}
		if err := validateConfirmSendScope(runtime); err != nil {
			return err
		}
		if err := validateSendTime(runtime); err != nil {
			return err
		}

		if runtime.Bool("confirm-send") && runtime.Str("template-id") == "" {
			if err := validateComposeHasAtLeastOneRecipient(runtime.Str("to"), runtime.Str("cc"), runtime.Str("bcc")); err != nil {
				return err
			}
		}
		if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
			return err
		}
		if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), ""); err != nil {
			return err
		}
		return validatePriorityFlag(runtime)
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		messageId := runtime.Str("message-id")
		to := runtime.Str("to")
		body := runtime.Str("body")
		ccFlag := runtime.Str("cc")
		bccFlag := runtime.Str("bcc")
		plainText := runtime.Bool("plain-text")
		attachFlag := runtime.Str("attach")
		inlineFlag := runtime.Str("inline")
		confirmSend := runtime.Bool("confirm-send")
		sendTime := runtime.Str("send-time")

		priority, err := parsePriority(runtime.Str("priority"))
		if err != nil {
			return err
		}

		signatureID := runtime.Str("signature-id")
		mailboxID := resolveComposeMailboxID(runtime)
		sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
		if sigErr != nil {
			return sigErr
		}
		sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
		if err != nil {
			return fmt.Errorf("failed to fetch original message: %w", err)
		}
		if err := validateForwardAttachmentURLs(sourceMsg); err != nil {
			return fmt.Errorf("forward blocked: %w", err)
		}
		orig := sourceMsg.Original

		resolvedSender := resolveComposeSenderEmail(runtime)

		if err := requireSenderForRequestReceipt(runtime, resolvedSender); err != nil {
			return err
		}
		senderEmail := resolvedSender
		if senderEmail == "" {
			senderEmail = orig.headTo
		}

		// --template-id merge (§5.5 Q1-Q5).
		var templateLargeAttachmentIDs []string
		var templateInlineAttachments []templateInlineRef
		var templateSmallAttachments []templateAttachmentRef
		templateID := runtime.Str("template-id")
		if tid := templateID; tid != "" {
			tpl, tErr := fetchTemplate(runtime, mailboxID, tid)
			if tErr != nil {
				return tErr
			}
			merged := applyTemplate(
				templateShortcutForward, tpl,
				to, ccFlag, bccFlag,
				buildForwardSubject(orig.subject), body,
				"", "", "", runtime.Str("subject"), "",
			)
			to = merged.To
			ccFlag = merged.Cc
			bccFlag = merged.Bcc
			body = merged.Body
			if !plainText && merged.IsPlainTextMode {
				plainText = true
			}
			templateLargeAttachmentIDs = merged.LargeAttachmentIDs
			templateInlineAttachments = merged.InlineAttachments
			templateSmallAttachments = merged.SmallAttachments
			for _, w := range merged.Warnings {
				fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w)
			}
			inlineCount, largeCount := countAttachmentsByType(tpl.Attachments)
			logTemplateInfo(runtime, "apply.forward", map[string]interface{}{
				"mailbox_id":         mailboxID,
				"template_id":        tid,
				"is_plain_text_mode": plainText,
				"attachments_total":  len(tpl.Attachments),
				"inline_count":       inlineCount,
				"large_count":        largeCount,
				"tos_count":          countAddresses(to),
				"ccs_count":          countAddresses(ccFlag),
				"bccs_count":         countAddresses(bccFlag),
			})
		}
		subjectOverride := strings.TrimSpace(runtime.Str("subject"))

		if confirmSend && templateID != "" {
			if err := validateComposeHasAtLeastOneRecipient(to, ccFlag, bccFlag); err != nil {
				return err
			}
		}

		if err := validateRecipientCount(to, ccFlag, bccFlag); err != nil {
			return err
		}

		subjectLine := buildForwardSubject(orig.subject)
		if subjectOverride != "" {
			subjectLine = subjectOverride
		}
		bld := emlbuilder.New().WithFileIO(runtime.FileIO()).
			Subject(subjectLine).
			ToAddrs(parseNetAddrs(to))
		if senderEmail != "" {
			bld = bld.From("", senderEmail)
		}

		if runtime.Bool("request-receipt") {
			bld = bld.DispositionNotificationTo("", senderEmail)
		}
		if ccFlag != "" {
			bld = bld.CCAddrs(parseNetAddrs(ccFlag))
		}
		if bccFlag != "" {
			bld = bld.BCCAddrs(parseNetAddrs(bccFlag))
		}
		if inReplyTo := normalizeMessageID(orig.smtpMessageId); inReplyTo != "" {
			bld = bld.InReplyTo(inReplyTo)
		}
		if messageId != "" {
			bld = bld.LMSReplyToMessageID(messageId)
		}
		useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil)
		if strings.TrimSpace(inlineFlag) != "" && !useHTML {
			return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
		}
		inlineSpecs, err := parseInlineSpecs(inlineFlag)
		if err != nil {
			return err
		}
		var autoResolvedPaths []string
		var composedHTMLBody string
		var composedTextBody string
		var srcInlineBytes int64
		if useHTML {
			if err := validateInlineImageURLs(sourceMsg); err != nil {
				return fmt.Errorf("forward blocked: %w", err)
			}
			processedBody := buildBodyDiv(body, bodyIsHTML(body))
			origLargeAttCard := stripLargeAttachmentCard(&orig)
			for id := range sourceMsg.FailedAttachmentIDs {
				if updated, ok := draftpkg.RemoveLargeFileItemFromHTML(origLargeAttCard, id); ok {
					origLargeAttCard = updated
				}
			}
			forwardQuote := buildForwardQuoteHTML(&orig)
			var srcCIDs []string
			bld, srcCIDs, srcInlineBytes, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages)
			if err != nil {
				return err
			}
			resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(processedBody)
			if resolveErr != nil {
				return resolveErr
			}
			bodyWithSig := resolved
			if sigResult != nil {
				bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent)
			}
			composedHTMLBody = bodyWithSig + origLargeAttCard + forwardQuote
			bld = bld.HTMLBody([]byte(composedHTMLBody))
			bld = addSignatureImagesToBuilder(bld, sigResult)
			var userCIDs []string
			for _, ref := range refs {
				bld = bld.AddFileInline(ref.FilePath, ref.CID)
				autoResolvedPaths = append(autoResolvedPaths, ref.FilePath)
				userCIDs = append(userCIDs, ref.CID)
			}
			for _, spec := range inlineSpecs {
				bld = bld.AddFileInline(spec.FilePath, spec.CID)
				userCIDs = append(userCIDs, spec.CID)
			}
			var tplInlineCIDs []string
			bld, tplInlineCIDs, err = embedTemplateInlineAttachments(ctx, runtime, bld, bodyWithSig, mailboxID, templateID, templateInlineAttachments)
			if err != nil {
				return err
			}
			userCIDs = append(userCIDs, tplInlineCIDs...)
			if err := validateInlineCIDs(bodyWithSig, append(userCIDs, signatureCIDs(sigResult)...), srcCIDs); err != nil {
				return err
			}
		} else {
			composedTextBody = buildForwardedMessage(&orig, body)
			bld = bld.TextBody([]byte(composedTextBody))
		}
		// Embed template SMALL non-inline attachments regardless of body mode.
		// Template LARGE entries keep going through the X-Lms-Large-Attachment-Ids
		// header below; inline already ran in the HTML branch above.
		var templateSmallBytes int64
		bld, templateSmallBytes, err = embedTemplateSmallAttachments(ctx, runtime, bld, mailboxID, templateID, templateSmallAttachments)
		if err != nil {
			return err
		}
		bld = applyPriority(bld, priority)
		// Download original attachments, separating normal from large.
		type downloadedAtt struct {
			content     []byte
			contentType string
			filename    string
		}
		var origAtts []downloadedAtt
		var largeAttIDs []largeAttID
		var skippedAtts []string
		for _, att := range sourceMsg.ForwardAttachments {
			if sourceMsg.FailedAttachmentIDs[att.ID] {
				skippedAtts = append(skippedAtts, att.Filename)
				continue
			}
			if att.AttachmentType == attachmentTypeLarge {
				largeAttIDs = append(largeAttIDs, largeAttID{ID: att.ID})
				continue
			}
			content, err := downloadAttachmentContent(runtime, att.DownloadURL)
			if err != nil {
				return fmt.Errorf("failed to download original attachment %s: %w", att.Filename, err)
			}
			contentType := att.ContentType
			if contentType == "" {
				contentType = "application/octet-stream"
			}
			origAtts = append(origAtts, downloadedAtt{content, contentType, att.Filename})
		}
		if len(skippedAtts) > 0 {
			fmt.Fprintf(runtime.IO().ErrOut, "warning: skipped %d invalid attachment(s): %s\n",
				len(skippedAtts), strings.Join(skippedAtts, ", "))
		}

		allInlinePaths := append(inlineSpecFilePaths(inlineSpecs), autoResolvedPaths...)
		composedBodySize := int64(len(composedHTMLBody) + len(composedTextBody))
		emlBase := estimateEMLBaseSize(runtime.FileIO(), composedBodySize, allInlinePaths, srcInlineBytes) + templateSmallBytes

		var allFiles []attachmentFile
		for i, att := range origAtts {
			allFiles = append(allFiles, attachmentFile{
				FileName:    att.filename,
				Size:        int64(len(att.content)),
				SourceIndex: i,
			})
		}
		userFiles, err := statAttachmentFiles(runtime.FileIO(), splitByComma(attachFlag))
		if err != nil {
			return err
		}
		for _, f := range userFiles {
			if f.Size > MaxLargeAttachmentSize {
				return output.ErrValidation("attachment %s (%.1f GB) exceeds the %.0f GB single file limit",
					f.FileName, float64(f.Size)/1024/1024/1024, float64(MaxLargeAttachmentSize)/1024/1024/1024)
			}
		}
		totalCount := len(origAtts) + len(largeAttIDs) + len(userFiles)
		if totalCount > MaxAttachmentCount {
			return output.ErrValidation("attachment count %d exceeds the limit of %d", totalCount, MaxAttachmentCount)
		}
		allFiles = append(allFiles, userFiles...)
		classified := classifyAttachments(allFiles, emlBase)

		for _, f := range classified.Normal {
			if f.Path == "" {
				att := origAtts[f.SourceIndex]
				bld = bld.AddAttachment(att.content, "application/octet-stream", att.filename)
			} else {
				bld = bld.AddFileAttachment(f.Path)
			}
		}

		if len(classified.Oversized) > 0 {
			if composedHTMLBody == "" && composedTextBody == "" {
				return output.ErrValidation("large attachments require a body; " +
					"empty messages cannot include the download link")
			}
			if runtime.Config == nil || runtime.UserOpenId() == "" {
				var totalBytes int64
				for _, f := range classified.Oversized {
					totalBytes += f.Size
				}
				return output.ErrValidation("total attachment size %.1f MB exceeds the 25 MB EML limit; "+
					"large attachment upload requires user identity (--as user)",
					float64(totalBytes)/1024/1024)
			}

			var allOversized []attachmentFile
			for _, f := range classified.Oversized {
				if f.Path == "" {
					att := origAtts[f.SourceIndex]
					allOversized = append(allOversized, attachmentFile{
						FileName: att.filename,
						Size:     int64(len(att.content)),
						Data:     att.content,
					})
				} else {
					allOversized = append(allOversized, f)
				}
			}
			uploadResults, err := uploadLargeAttachments(ctx, runtime, allOversized)
			if err != nil {
				return err
			}

			if composedHTMLBody != "" {
				largeHTML := buildLargeAttachmentHTML(runtime.Config.Brand, resolveLang(runtime), uploadResults)
				bld = bld.HTMLBody([]byte(draftpkg.InsertBeforeQuoteOrAppend(composedHTMLBody, largeHTML)))
			} else {
				largeText := buildLargeAttachmentPlainText(runtime.Config.Brand, resolveLang(runtime), uploadResults)
				bld = bld.TextBody([]byte(composedTextBody + largeText))
			}

			for _, r := range uploadResults {
				largeAttIDs = append(largeAttIDs, largeAttID{ID: r.FileToken})
			}

			fmt.Fprintf(runtime.IO().ErrOut, "  %d normal attachment(s) embedded in EML\n", len(classified.Normal))
			fmt.Fprintf(runtime.IO().ErrOut, "  %d large attachment(s) uploaded (download links in body)\n", len(classified.Oversized))
		}

		seenLargeID := make(map[string]bool, len(largeAttIDs)+len(templateLargeAttachmentIDs))
		mergedLargeAttIDs := make([]largeAttID, 0, len(largeAttIDs)+len(templateLargeAttachmentIDs))
		for _, e := range largeAttIDs {
			if e.ID == "" || seenLargeID[e.ID] {
				continue
			}
			seenLargeID[e.ID] = true
			mergedLargeAttIDs = append(mergedLargeAttIDs, e)
		}
		for _, id := range templateLargeAttachmentIDs {
			if id == "" || seenLargeID[id] {
				continue
			}
			seenLargeID[id] = true
			mergedLargeAttIDs = append(mergedLargeAttIDs, largeAttID{ID: id})
		}
		if len(mergedLargeAttIDs) > 0 {
			idsJSON, err := json.Marshal(mergedLargeAttIDs)
			if err != nil {
				return fmt.Errorf("failed to encode large attachment IDs: %w", err)
			}
			bld = bld.Header(draftpkg.LargeAttachmentIDsHeader, base64.StdEncoding.EncodeToString(idsJSON))
		}
		rawEML, err := bld.BuildBase64URL()
		if err != nil {
			return fmt.Errorf("failed to build EML: %w", err)
		}

		draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
		if err != nil {
			return fmt.Errorf("failed to create draft: %w", err)
		}
		if !confirmSend {
			runtime.Out(buildDraftSavedOutput(draftResult, mailboxID), nil)
			hintSendDraft(runtime, mailboxID, draftResult.DraftID)
			return nil
		}
		resData, err := draftpkg.Send(runtime, mailboxID, draftResult.DraftID, sendTime)
		if err != nil {
			return fmt.Errorf("failed to send forward (draft %s created but not sent): %w", draftResult.DraftID, err)
		}
		runtime.Out(buildDraftSendOutput(resData, mailboxID), nil)
		hintMarkAsRead(runtime, mailboxID, messageId)
		return nil
	},
}

MailForward is the `+forward` shortcut: forward an existing message to new recipients, saving a draft by default (or sending immediately with --confirm-send). Original message block is included automatically.

View Source
var MailMessage = common.Shortcut{
	Service:     "mail",
	Command:     "+message",
	Description: "Use when reading full content for a single email by message ID. Returns normalized body content plus attachments metadata, including inline images.",
	Risk:        "read",
	Scopes:      []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
	AuthTypes:   []string{"user", "bot"},
	HasFormat:   true,
	Flags: []common.Flag{
		{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
		{Name: "message-id", Desc: "Required. Email message ID", Required: true},
		{Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"},
		{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		mailboxID := resolveMailboxID(runtime)
		messageID := runtime.Str("message-id")
		return common.NewDryRunAPI().
			Desc("Fetch full email content and attachments metadata, including inline images").
			GET(mailboxPath(mailboxID, "messages", messageID))
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		if runtime.Bool("print-output-schema") {
			printMessageOutputSchema(runtime)
			return nil
		}
		mailboxID := resolveMailboxID(runtime)
		hintIdentityFirst(runtime, mailboxID)
		messageID := runtime.Str("message-id")
		html := runtime.Bool("html")

		msg, err := fetchFullMessage(runtime, mailboxID, messageID, html)
		if err != nil {
			return fmt.Errorf("failed to fetch email: %w", err)
		}

		out := buildMessageOutput(msg, html)
		runtime.Out(out, nil)
		maybeHintReadReceiptRequest(runtime, mailboxID, messageID, msg)
		return nil
	},
}

MailMessage is the `+message` shortcut: fetch full content of a single email by message ID (normalized body + attachments / inline metadata).

View Source
var MailMessages = common.Shortcut{
	Service:     "mail",
	Command:     "+messages",
	Description: "Use when reading full content for multiple emails by message ID. Prefer this shortcut over calling raw mail user_mailbox.messages batch_get directly, because it base64url-decodes body fields and returns normalized per-message output that is easier to consume.",
	Risk:        "read",
	Scopes:      []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
	AuthTypes:   []string{"user", "bot"},
	HasFormat:   true,
	Flags: []common.Flag{
		{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
		{Name: "message-ids", Desc: `Required. Comma-separated email message IDs. Example: "id1,id2,id3"`, Required: true},
		{Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"},
		{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		mailboxID := resolveMailboxID(runtime)
		messageIDs := splitByComma(runtime.Str("message-ids"))
		body := map[string]interface{}{
			"format":      messageGetFormat(runtime.Bool("html")),
			"message_ids": []string{"<message_id_1>", "<message_id_2>"},
		}
		if len(messageIDs) > 0 {
			body["message_ids"] = messageIDs
		}
		return common.NewDryRunAPI().
			Desc("Fetch multiple emails via messages.batch_get (auto-chunked in batches of 20 IDs during execution)").
			POST(mailboxPath(mailboxID, "messages", "batch_get")).
			Body(body)
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		if runtime.Bool("print-output-schema") {
			printMessageOutputSchema(runtime)
			return nil
		}
		mailboxID := resolveMailboxID(runtime)
		hintIdentityFirst(runtime, mailboxID)
		messageIDs := splitByComma(runtime.Str("message-ids"))
		if len(messageIDs) == 0 {
			return output.ErrValidation("--message-ids is required; provide one or more message IDs separated by commas")
		}
		html := runtime.Bool("html")

		rawMessages, missingMessageIDs, err := fetchFullMessages(runtime, mailboxID, messageIDs, html)
		if err != nil {
			return err
		}

		messages := make([]map[string]interface{}, 0, len(rawMessages))
		for _, msg := range rawMessages {
			messages = append(messages, buildMessageOutput(msg, html))
		}

		runtime.Out(mailMessagesOutput{
			Messages:              messages,
			Total:                 len(messages),
			UnavailableMessageIDs: missingMessageIDs,
		}, nil)
		for _, msg := range rawMessages {
			maybeHintReadReceiptRequest(runtime, mailboxID, strVal(msg["message_id"]), msg)
		}
		return nil
	},
}

MailMessages is the `+messages` shortcut: batch-fetch full content for up to 20 message IDs in a single call, preserving request order.

View Source
var MailReply = common.Shortcut{
	Service:     "mail",
	Command:     "+reply",
	Description: "Reply to a message and save as draft (default). Use --confirm-send to send immediately after user confirmation. Sets Re: subject, In-Reply-To, and References headers automatically.",
	Risk:        "write",
	Scopes:      []string{"mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
	AuthTypes:   []string{"user"},
	Flags: []common.Flag{
		{Name: "message-id", Desc: "Required. Message ID to reply to", Required: true},
		{Name: "body", Desc: "Reply body. Prefer HTML for rich formatting; plain text is also supported. Body type is auto-detected from the reply body and the original message. Use --plain-text to force plain-text mode. Required unless --template-id supplies a non-empty body."},
		{Name: "from", Desc: "Sender email address for the From header. When using an alias (send_as) address, set this to the alias and use --mailbox for the owning mailbox. Defaults to the mailbox's primary address."},
		{Name: "mailbox", Desc: "Mailbox email address that owns the draft (default: falls back to --from, then me). Use this when the sender (--from) differs from the mailbox, e.g. sending via an alias or send_as address."},
		{Name: "to", Desc: "Additional To address(es), comma-separated (appended to original sender's address)"},
		{Name: "cc", Desc: "Additional CC email address(es), comma-separated"},
		{Name: "bcc", Desc: "BCC email address(es), comma-separated"},
		{Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring all HTML auto-detection. Cannot be used with --inline."},
		{Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"},
		{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
		{Name: "confirm-send", Type: "bool", Desc: "Send the reply immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
		{Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."},
		{Name: "request-receipt", Type: "bool", Desc: "Request a read receipt (Message Disposition Notification, RFC 3798) addressed to the sender. Recipient mail clients may prompt the user, send automatically, or silently ignore — delivery of a receipt is not guaranteed."},
		{Name: "subject", Desc: "Optional. Override the auto-generated Re: subject. When set, the shortcut uses this value verbatim instead of prefixing the original subject."},
		{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's body/to/cc/bcc/attachments are appended to the reply-derived values (no de-duplication; see warning in Execute output)."},
		signatureFlag,
		priorityFlag},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		messageId := runtime.Str("message-id")
		confirmSend := runtime.Bool("confirm-send")
		mailboxID := resolveComposeMailboxID(runtime)
		desc := "Reply: fetch original message → resolve sender address → save as draft"
		if confirmSend {
			desc = "Reply (--confirm-send): fetch original message → resolve sender address → create draft → send draft"
		}
		api := common.NewDryRunAPI().Desc(desc)
		if tid := runtime.Str("template-id"); tid != "" {
			api = api.GET(templateMailboxPath(mailboxID, tid)).
				Desc("Fetch template to merge with reply-derived recipients / body.")
		}
		api = api.GET(mailboxPath(mailboxID, "messages", messageId)).
			GET(mailboxPath(mailboxID, "profile")).
			POST(mailboxPath(mailboxID, "drafts")).
			Body(map[string]interface{}{"raw": "<base64url-EML>"})
		if confirmSend {
			api = api.POST(mailboxPath(mailboxID, "drafts", "<draft_id>", "send"))
		}
		return api
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		if err := validateTemplateID(runtime.Str("template-id")); err != nil {
			return err
		}
		hasTemplate := runtime.Str("template-id") != ""
		if !hasTemplate && strings.TrimSpace(runtime.Str("body")) == "" {
			return output.ErrValidation("--body is required; pass the reply body (or use --template-id)")
		}
		if err := validateConfirmSendScope(runtime); err != nil {
			return err
		}
		if err := validateSendTime(runtime); err != nil {
			return err
		}
		if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
			return err
		}
		if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), ""); err != nil {
			return err
		}
		return validatePriorityFlag(runtime)
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		messageId := runtime.Str("message-id")
		body := runtime.Str("body")
		toFlag := runtime.Str("to")
		ccFlag := runtime.Str("cc")
		bccFlag := runtime.Str("bcc")
		plainText := runtime.Bool("plain-text")
		attachFlag := runtime.Str("attach")
		inlineFlag := runtime.Str("inline")
		confirmSend := runtime.Bool("confirm-send")
		sendTime := runtime.Str("send-time")

		priority, err := parsePriority(runtime.Str("priority"))
		if err != nil {
			return err
		}

		inlineSpecs, err := parseInlineSpecs(inlineFlag)
		if err != nil {
			return err
		}

		signatureID := runtime.Str("signature-id")
		mailboxID := resolveComposeMailboxID(runtime)
		sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
		if sigErr != nil {
			return sigErr
		}
		sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
		if err != nil {
			return fmt.Errorf("failed to fetch original message: %w", err)
		}
		orig := sourceMsg.Original
		stripLargeAttachmentCard(&orig)

		resolvedSender := resolveComposeSenderEmail(runtime)

		if err := requireSenderForRequestReceipt(runtime, resolvedSender); err != nil {
			return err
		}
		senderEmail := resolvedSender
		if senderEmail == "" {
			senderEmail = orig.headTo
		}

		replyTo := orig.replyTo
		if replyTo == "" {
			replyTo = orig.headFrom
		}
		replyTo = mergeAddrLists(replyTo, toFlag)

		// --template-id merge (§5.5 Q1-Q5).
		var templateLargeAttachmentIDs []string
		var templateInlineAttachments []templateInlineRef
		var templateSmallAttachments []templateAttachmentRef
		templateID := runtime.Str("template-id")
		if tid := templateID; tid != "" {
			tpl, tErr := fetchTemplate(runtime, mailboxID, tid)
			if tErr != nil {
				return tErr
			}
			merged := applyTemplate(
				templateShortcutReply, tpl,
				replyTo, ccFlag, bccFlag,
				buildReplySubject(orig.subject), body,
				"", "", "", runtime.Str("subject"), "",
			)
			replyTo = merged.To
			ccFlag = merged.Cc
			bccFlag = merged.Bcc
			body = merged.Body
			if !plainText && merged.IsPlainTextMode {
				plainText = true
			}
			templateLargeAttachmentIDs = merged.LargeAttachmentIDs
			templateInlineAttachments = merged.InlineAttachments
			templateSmallAttachments = merged.SmallAttachments
			for _, w := range merged.Warnings {
				fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w)
			}

			inlineCount, largeCount := countAttachmentsByType(tpl.Attachments)
			logTemplateInfo(runtime, "apply.reply", map[string]interface{}{
				"mailbox_id":         mailboxID,
				"template_id":        tid,
				"is_plain_text_mode": plainText,
				"attachments_total":  len(tpl.Attachments),
				"inline_count":       inlineCount,
				"large_count":        largeCount,
				"tos_count":          countAddresses(replyTo),
				"ccs_count":          countAddresses(ccFlag),
				"bccs_count":         countAddresses(bccFlag),
			})
		}

		subjectOverride := strings.TrimSpace(runtime.Str("subject"))

		useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil)
		if strings.TrimSpace(inlineFlag) != "" && !useHTML {
			return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
		}
		var bodyStr string
		if useHTML {
			bodyStr = buildBodyDiv(body, bodyIsHTML(body))
		} else {
			bodyStr = body
		}
		if err := validateRecipientCount(replyTo, ccFlag, bccFlag); err != nil {
			return err
		}

		quoted := quoteForReply(&orig, useHTML)
		subjectLine := buildReplySubject(orig.subject)
		if subjectOverride != "" {
			subjectLine = subjectOverride
		}
		bld := emlbuilder.New().WithFileIO(runtime.FileIO()).
			Subject(subjectLine).
			ToAddrs(parseNetAddrs(replyTo))
		if senderEmail != "" {
			bld = bld.From("", senderEmail)
		}

		if runtime.Bool("request-receipt") {
			bld = bld.DispositionNotificationTo("", senderEmail)
		}
		if ccFlag != "" {
			bld = bld.CCAddrs(parseNetAddrs(ccFlag))
		}
		if bccFlag != "" {
			bld = bld.BCCAddrs(parseNetAddrs(bccFlag))
		}
		if inReplyTo := normalizeMessageID(orig.smtpMessageId); inReplyTo != "" {
			bld = bld.InReplyTo(inReplyTo)
		}
		if messageId != "" {
			bld = bld.LMSReplyToMessageID(messageId)
		}
		var autoResolvedPaths []string
		var composedHTMLBody string
		var composedTextBody string
		var srcInlineBytes int64
		if useHTML {
			if err := validateInlineImageURLs(sourceMsg); err != nil {
				return fmt.Errorf("HTML reply blocked: %w", err)
			}
			var srcCIDs []string
			bld, srcCIDs, srcInlineBytes, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages)
			if err != nil {
				return err
			}
			resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(bodyStr)
			if resolveErr != nil {
				return resolveErr
			}
			bodyWithSig := resolved
			if sigResult != nil {
				bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent)
			}
			composedHTMLBody = bodyWithSig + quoted
			bld = bld.HTMLBody([]byte(composedHTMLBody))
			bld = addSignatureImagesToBuilder(bld, sigResult)
			var userCIDs []string
			for _, ref := range refs {
				bld = bld.AddFileInline(ref.FilePath, ref.CID)
				autoResolvedPaths = append(autoResolvedPaths, ref.FilePath)
				userCIDs = append(userCIDs, ref.CID)
			}
			for _, spec := range inlineSpecs {
				bld = bld.AddFileInline(spec.FilePath, spec.CID)
				userCIDs = append(userCIDs, spec.CID)
			}
			var tplInlineCIDs []string
			bld, tplInlineCIDs, err = embedTemplateInlineAttachments(ctx, runtime, bld, bodyWithSig, mailboxID, templateID, templateInlineAttachments)
			if err != nil {
				return err
			}
			userCIDs = append(userCIDs, tplInlineCIDs...)
			if err := validateInlineCIDs(bodyWithSig, append(userCIDs, signatureCIDs(sigResult)...), srcCIDs); err != nil {
				return err
			}
		} else {
			composedTextBody = bodyStr + quoted
			bld = bld.TextBody([]byte(composedTextBody))
		}
		// Embed template SMALL non-inline attachments regardless of body mode.
		var templateSmallBytes int64
		bld, templateSmallBytes, err = embedTemplateSmallAttachments(ctx, runtime, bld, mailboxID, templateID, templateSmallAttachments)
		if err != nil {
			return err
		}
		bld = applyPriority(bld, priority)
		allInlinePaths := append(inlineSpecFilePaths(inlineSpecs), autoResolvedPaths...)
		composedBodySize := int64(len(composedHTMLBody) + len(composedTextBody))
		emlBase := estimateEMLBaseSize(runtime.FileIO(), composedBodySize, allInlinePaths, srcInlineBytes) + templateSmallBytes
		bld, err = processLargeAttachments(ctx, runtime, bld, composedHTMLBody, composedTextBody, splitByComma(attachFlag), emlBase, 0)
		if err != nil {
			return err
		}
		if hdr, hdrErr := encodeTemplateLargeAttachmentHeader(templateLargeAttachmentIDs); hdrErr == nil && hdr != "" {
			bld = bld.Header(draftpkg.LargeAttachmentIDsHeader, hdr)
		}
		rawEML, err := bld.BuildBase64URL()
		if err != nil {
			return fmt.Errorf("failed to build EML: %w", err)
		}

		draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
		if err != nil {
			return fmt.Errorf("failed to create draft: %w", err)
		}
		if !confirmSend {
			runtime.Out(buildDraftSavedOutput(draftResult, mailboxID), nil)
			hintSendDraft(runtime, mailboxID, draftResult.DraftID)
			return nil
		}
		resData, err := draftpkg.Send(runtime, mailboxID, draftResult.DraftID, sendTime)
		if err != nil {
			return fmt.Errorf("failed to send reply (draft %s created but not sent): %w", draftResult.DraftID, err)
		}
		runtime.Out(buildDraftSendOutput(resData, mailboxID), nil)
		hintMarkAsRead(runtime, mailboxID, messageId)
		return nil
	},
}

MailReply is the `+reply` shortcut: reply to the sender of a message, saving a draft by default (or sending immediately with --confirm-send). Automatically sets Re: subject, In-Reply-To, and References headers.

View Source
var MailReplyAll = common.Shortcut{
	Service:     "mail",
	Command:     "+reply-all",
	Description: "Reply to all recipients and save as draft (default). Use --confirm-send to send immediately after user confirmation. Includes all original To and CC automatically.",
	Risk:        "write",
	Scopes:      []string{"mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
	AuthTypes:   []string{"user"},
	Flags: []common.Flag{
		{Name: "message-id", Desc: "Required. Message ID to reply to all recipients", Required: true},
		{Name: "body", Desc: "Reply body. Prefer HTML for rich formatting; plain text is also supported. Body type is auto-detected from the reply body and the original message. Use --plain-text to force plain-text mode. Required unless --template-id supplies a non-empty body."},
		{Name: "from", Desc: "Sender email address for the From header. When using an alias (send_as) address, set this to the alias and use --mailbox for the owning mailbox. Defaults to the mailbox's primary address."},
		{Name: "mailbox", Desc: "Mailbox email address that owns the draft (default: falls back to --from, then me). Use this when the sender (--from) differs from the mailbox, e.g. sending via an alias or send_as address."},
		{Name: "to", Desc: "Additional To address(es), comma-separated (appended to original recipients)"},
		{Name: "cc", Desc: "Additional CC email address(es), comma-separated"},
		{Name: "bcc", Desc: "BCC email address(es), comma-separated"},
		{Name: "remove", Desc: "Address(es) to exclude from the outgoing reply, comma-separated"},
		{Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring all HTML auto-detection. Cannot be used with --inline."},
		{Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"},
		{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
		{Name: "confirm-send", Type: "bool", Desc: "Send the reply immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
		{Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."},
		{Name: "request-receipt", Type: "bool", Desc: "Request a read receipt (Message Disposition Notification, RFC 3798) addressed to the sender. Recipient mail clients may prompt the user, send automatically, or silently ignore — delivery of a receipt is not guaranteed."},
		{Name: "subject", Desc: "Optional. Override the auto-generated Re: subject. When set, the shortcut uses this value verbatim instead of prefixing the original subject."},
		{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's body/to/cc/bcc/attachments are appended to the reply-derived values (no de-duplication; see warning in Execute output)."},
		signatureFlag,
		priorityFlag},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		messageId := runtime.Str("message-id")
		confirmSend := runtime.Bool("confirm-send")
		mailboxID := resolveComposeMailboxID(runtime)
		desc := "Reply-all: fetch original message (with recipients) → resolve sender address → save as draft"
		if confirmSend {
			desc = "Reply-all (--confirm-send): fetch original message (with recipients) → resolve sender address → create draft → send draft"
		}
		api := common.NewDryRunAPI().Desc(desc)
		if tid := runtime.Str("template-id"); tid != "" {
			api = api.GET(templateMailboxPath(mailboxID, tid)).
				Desc("Fetch template to merge with reply-all-derived recipients / body.")
		}
		api = api.GET(mailboxPath(mailboxID, "messages", messageId)).
			GET(mailboxPath(mailboxID, "profile")).
			POST(mailboxPath(mailboxID, "drafts")).
			Body(map[string]interface{}{"raw": "<base64url-EML>"})
		if confirmSend {
			api = api.POST(mailboxPath(mailboxID, "drafts", "<draft_id>", "send"))
		}
		return api
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		if err := validateTemplateID(runtime.Str("template-id")); err != nil {
			return err
		}
		hasTemplate := runtime.Str("template-id") != ""
		if !hasTemplate && strings.TrimSpace(runtime.Str("body")) == "" {
			return output.ErrValidation("--body is required; pass the reply body (or use --template-id)")
		}
		if err := validateConfirmSendScope(runtime); err != nil {
			return err
		}
		if err := validateSendTime(runtime); err != nil {
			return err
		}
		if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
			return err
		}
		if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), ""); err != nil {
			return err
		}
		return validatePriorityFlag(runtime)
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		messageId := runtime.Str("message-id")
		body := runtime.Str("body")
		toFlag := runtime.Str("to")
		ccFlag := runtime.Str("cc")
		bccFlag := runtime.Str("bcc")
		removeFlag := runtime.Str("remove")
		plainText := runtime.Bool("plain-text")
		attachFlag := runtime.Str("attach")
		inlineFlag := runtime.Str("inline")
		confirmSend := runtime.Bool("confirm-send")
		sendTime := runtime.Str("send-time")

		priority, err := parsePriority(runtime.Str("priority"))
		if err != nil {
			return err
		}

		inlineSpecs, err := parseInlineSpecs(inlineFlag)
		if err != nil {
			return err
		}

		signatureID := runtime.Str("signature-id")
		mailboxID := resolveComposeMailboxID(runtime)
		sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
		if sigErr != nil {
			return sigErr
		}
		sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
		if err != nil {
			return fmt.Errorf("failed to fetch original message: %w", err)
		}
		orig := sourceMsg.Original
		stripLargeAttachmentCard(&orig)

		resolvedSender := resolveComposeSenderEmail(runtime)

		if err := requireSenderForRequestReceipt(runtime, resolvedSender); err != nil {
			return err
		}
		senderEmail := resolvedSender
		if senderEmail == "" {
			senderEmail = orig.headTo
		}

		var removeList []string
		for _, r := range strings.Split(removeFlag, ",") {
			if s := strings.TrimSpace(r); s != "" {
				removeList = append(removeList, s)
			}
		}
		selfEmails := fetchSelfEmailSet(runtime, mailboxID)
		excluded := buildExcludeSet(selfEmails, removeList)
		replyToAddr := orig.replyTo
		if replyToAddr == "" {
			replyToAddr = orig.headFrom
		}
		isSelfSent := selfEmails[strings.ToLower(orig.headFrom)] || (senderEmail != "" && strings.EqualFold(orig.headFrom, senderEmail))
		toList, ccList := buildReplyAllRecipients(replyToAddr, orig.toAddresses, orig.ccAddresses, senderEmail, excluded, isSelfSent)

		toList = mergeAddrLists(toList, toFlag)
		ccList = mergeAddrLists(ccList, ccFlag)

		// --template-id merge (§5.5 Q1-Q5).
		var templateLargeAttachmentIDs []string
		var templateInlineAttachments []templateInlineRef
		var templateSmallAttachments []templateAttachmentRef
		templateID := runtime.Str("template-id")
		if tid := templateID; tid != "" {
			tpl, tErr := fetchTemplate(runtime, mailboxID, tid)
			if tErr != nil {
				return tErr
			}
			merged := applyTemplate(
				templateShortcutReplyAll, tpl,
				toList, ccList, bccFlag,
				buildReplySubject(orig.subject), body,
				"", "", "", runtime.Str("subject"), "",
			)
			toList = merged.To
			ccList = merged.Cc
			bccFlag = merged.Bcc
			body = merged.Body
			if !plainText && merged.IsPlainTextMode {
				plainText = true
			}
			templateLargeAttachmentIDs = merged.LargeAttachmentIDs
			templateInlineAttachments = merged.InlineAttachments
			templateSmallAttachments = merged.SmallAttachments
			for _, w := range merged.Warnings {
				fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w)
			}
			inlineCount, largeCount := countAttachmentsByType(tpl.Attachments)
			logTemplateInfo(runtime, "apply.reply_all", map[string]interface{}{
				"mailbox_id":         mailboxID,
				"template_id":        tid,
				"is_plain_text_mode": plainText,
				"attachments_total":  len(tpl.Attachments),
				"inline_count":       inlineCount,
				"large_count":        largeCount,
				"tos_count":          countAddresses(toList),
				"ccs_count":          countAddresses(ccList),
				"bccs_count":         countAddresses(bccFlag),
			})
		}
		subjectOverride := strings.TrimSpace(runtime.Str("subject"))

		if err := validateRecipientCount(toList, ccList, bccFlag); err != nil {
			return err
		}

		useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil)
		if strings.TrimSpace(inlineFlag) != "" && !useHTML {
			return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
		}
		var bodyStr string
		if useHTML {
			bodyStr = buildBodyDiv(body, bodyIsHTML(body))
		} else {
			bodyStr = body
		}
		quoted := quoteForReply(&orig, useHTML)
		subjectLine := buildReplySubject(orig.subject)
		if subjectOverride != "" {
			subjectLine = subjectOverride
		}
		bld := emlbuilder.New().WithFileIO(runtime.FileIO()).
			Subject(subjectLine).
			ToAddrs(parseNetAddrs(toList))
		if senderEmail != "" {
			bld = bld.From("", senderEmail)
		}

		if runtime.Bool("request-receipt") {
			bld = bld.DispositionNotificationTo("", senderEmail)
		}
		if ccList != "" {
			bld = bld.CCAddrs(parseNetAddrs(ccList))
		}
		if bccFlag != "" {
			bld = bld.BCCAddrs(parseNetAddrs(bccFlag))
		}
		if inReplyTo := normalizeMessageID(orig.smtpMessageId); inReplyTo != "" {
			bld = bld.InReplyTo(inReplyTo)
		}
		if messageId != "" {
			bld = bld.LMSReplyToMessageID(messageId)
		}
		var autoResolvedPaths []string
		var composedHTMLBody string
		var composedTextBody string
		var srcInlineBytes int64
		if useHTML {
			if err := validateInlineImageURLs(sourceMsg); err != nil {
				return fmt.Errorf("HTML reply-all blocked: %w", err)
			}
			var srcCIDs []string
			bld, srcCIDs, srcInlineBytes, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages)
			if err != nil {
				return err
			}
			resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(bodyStr)
			if resolveErr != nil {
				return resolveErr
			}
			bodyWithSig := resolved
			if sigResult != nil {
				bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent)
			}
			composedHTMLBody = bodyWithSig + quoted
			bld = bld.HTMLBody([]byte(composedHTMLBody))
			bld = addSignatureImagesToBuilder(bld, sigResult)
			var userCIDs []string
			for _, ref := range refs {
				bld = bld.AddFileInline(ref.FilePath, ref.CID)
				autoResolvedPaths = append(autoResolvedPaths, ref.FilePath)
				userCIDs = append(userCIDs, ref.CID)
			}
			for _, spec := range inlineSpecs {
				bld = bld.AddFileInline(spec.FilePath, spec.CID)
				userCIDs = append(userCIDs, spec.CID)
			}
			var tplInlineCIDs []string
			bld, tplInlineCIDs, err = embedTemplateInlineAttachments(ctx, runtime, bld, bodyWithSig, mailboxID, templateID, templateInlineAttachments)
			if err != nil {
				return err
			}
			userCIDs = append(userCIDs, tplInlineCIDs...)
			if err := validateInlineCIDs(bodyWithSig, append(userCIDs, signatureCIDs(sigResult)...), srcCIDs); err != nil {
				return err
			}
		} else {
			composedTextBody = bodyStr + quoted
			bld = bld.TextBody([]byte(composedTextBody))
		}
		// Embed template SMALL non-inline attachments regardless of body mode.
		var templateSmallBytes int64
		bld, templateSmallBytes, err = embedTemplateSmallAttachments(ctx, runtime, bld, mailboxID, templateID, templateSmallAttachments)
		if err != nil {
			return err
		}
		bld = applyPriority(bld, priority)
		allInlinePaths := append(inlineSpecFilePaths(inlineSpecs), autoResolvedPaths...)
		composedBodySize := int64(len(composedHTMLBody) + len(composedTextBody))
		emlBase := estimateEMLBaseSize(runtime.FileIO(), composedBodySize, allInlinePaths, srcInlineBytes) + templateSmallBytes
		bld, err = processLargeAttachments(ctx, runtime, bld, composedHTMLBody, composedTextBody, splitByComma(attachFlag), emlBase, 0)
		if err != nil {
			return err
		}
		if hdr, hdrErr := encodeTemplateLargeAttachmentHeader(templateLargeAttachmentIDs); hdrErr == nil && hdr != "" {
			bld = bld.Header(draftpkg.LargeAttachmentIDsHeader, hdr)
		}
		rawEML, err := bld.BuildBase64URL()
		if err != nil {
			return fmt.Errorf("failed to build EML: %w", err)
		}

		draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
		if err != nil {
			return fmt.Errorf("failed to create draft: %w", err)
		}
		if !confirmSend {
			runtime.Out(buildDraftSavedOutput(draftResult, mailboxID), nil)
			hintSendDraft(runtime, mailboxID, draftResult.DraftID)
			return nil
		}
		resData, err := draftpkg.Send(runtime, mailboxID, draftResult.DraftID, sendTime)
		if err != nil {
			return fmt.Errorf("failed to send reply-all (draft %s created but not sent): %w", draftResult.DraftID, err)
		}
		runtime.Out(buildDraftSendOutput(resData, mailboxID), nil)
		hintMarkAsRead(runtime, mailboxID, messageId)
		return nil
	},
}

MailReplyAll is the `+reply-all` shortcut: reply to the sender plus all recipients of a message (with address dedup and self-exclusion), saving a draft by default (or sending immediately with --confirm-send).

View Source
var MailSend = common.Shortcut{
	Service:     "mail",
	Command:     "+send",
	Description: "Compose a new email and save as draft (default). Use --confirm-send to send immediately after user confirmation.",
	Risk:        "write",
	Scopes:      []string{"mail:user_mailbox.message:send", "mail:user_mailbox.message:modify", "mail:user_mailbox:readonly"},
	AuthTypes:   []string{"user"},
	Flags: []common.Flag{
		{Name: "to", Desc: "Recipient email address(es), comma-separated"},
		{Name: "subject", Desc: "Email subject. Required unless --template-id supplies a non-empty subject."},
		{Name: "body", Desc: "Email body. Prefer HTML for rich formatting (bold, lists, links); plain text is also supported. Body type is auto-detected. Use --plain-text to force plain-text mode. Required unless --template-id supplies a non-empty body."},
		{Name: "from", Desc: "Sender email address for the From header. When using an alias (send_as) address, set this to the alias and use --mailbox for the owning mailbox. Defaults to the mailbox's primary address."},
		{Name: "mailbox", Desc: "Mailbox email address that owns the draft (default: falls back to --from, then me). Use this when the sender (--from) differs from the mailbox, e.g. sending via an alias or send_as address."},
		{Name: "cc", Desc: "CC email address(es), comma-separated"},
		{Name: "bcc", Desc: "BCC email address(es), comma-separated"},
		{Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring HTML auto-detection. Cannot be used with --inline."},
		{Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"},
		{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
		{Name: "confirm-send", Type: "bool", Desc: "Send the email immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
		{Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."},
		{Name: "request-receipt", Type: "bool", Desc: "Request a read receipt (Message Disposition Notification, RFC 3798) addressed to the sender. Recipient mail clients may prompt the user, send automatically, or silently ignore — delivery of a receipt is not guaranteed."},
		{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's subject/body/to/cc/bcc/attachments are merged with user-supplied flags (user flags win). Requires --as user."},
		signatureFlag,
		priorityFlag},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		to := runtime.Str("to")
		subject := runtime.Str("subject")
		confirmSend := runtime.Bool("confirm-send")
		mailboxID := resolveComposeMailboxID(runtime)
		desc := "Compose email → save as draft"
		if confirmSend {
			desc = "Compose email → save as draft → send draft"
		}
		api := common.NewDryRunAPI().Desc(desc)
		if tid := runtime.Str("template-id"); tid != "" {
			api = api.GET(templateMailboxPath(mailboxID, tid)).
				Desc("Fetch template to merge with compose flags (subject/body/to/cc/bcc/attachments).")
		}
		api = api.GET(mailboxPath(mailboxID, "profile")).
			POST(mailboxPath(mailboxID, "drafts")).
			Body(map[string]interface{}{
				"raw": "<base64url-EML>",
				"_preview": map[string]interface{}{
					"to":      to,
					"subject": subject,
				},
			})
		if confirmSend {
			api = api.POST(mailboxPath(mailboxID, "drafts", "<draft_id>", "send"))
		}
		return api
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		if err := validateTemplateID(runtime.Str("template-id")); err != nil {
			return err
		}
		hasTemplate := runtime.Str("template-id") != ""
		if !hasTemplate && strings.TrimSpace(runtime.Str("subject")) == "" {
			return output.ErrValidation("--subject is required; pass the final email subject (or use --template-id)")
		}
		if !hasTemplate && strings.TrimSpace(runtime.Str("body")) == "" {
			return output.ErrValidation("--body is required; pass the full email body (or use --template-id)")
		}

		if !hasTemplate {
			if err := validateComposeHasAtLeastOneRecipient(runtime.Str("to"), runtime.Str("cc"), runtime.Str("bcc")); err != nil {
				return err
			}
		}
		if err := validateSendTime(runtime); err != nil {
			return err
		}
		if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
			return err
		}
		if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")); err != nil {
			return err
		}
		return validatePriorityFlag(runtime)
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		to := runtime.Str("to")
		subject := runtime.Str("subject")
		body := runtime.Str("body")
		ccFlag := runtime.Str("cc")
		bccFlag := runtime.Str("bcc")
		plainText := runtime.Bool("plain-text")
		attachFlag := runtime.Str("attach")
		inlineFlag := runtime.Str("inline")
		confirmSend := runtime.Bool("confirm-send")
		sendTime := runtime.Str("send-time")

		senderEmail := resolveComposeSenderEmail(runtime)
		signatureID := runtime.Str("signature-id")
		priority, err := parsePriority(runtime.Str("priority"))
		if err != nil {
			return err
		}

		mailboxID := resolveComposeMailboxID(runtime)

		// --template-id merge: fetch template and apply it to compose state.
		var templateLargeAttachmentIDs []string
		var templateInlineAttachments []templateInlineRef
		var templateSmallAttachments []templateAttachmentRef
		templateID := runtime.Str("template-id")
		if tid := templateID; tid != "" {
			tpl, err := fetchTemplate(runtime, mailboxID, tid)
			if err != nil {
				return err
			}
			merged := applyTemplate(
				templateShortcutSend, tpl,
				"", "", "",
				"", "",
				to, ccFlag, bccFlag, subject, body,
			)
			to = merged.To
			ccFlag = merged.Cc
			bccFlag = merged.Bcc
			subject = merged.Subject
			body = merged.Body
			if !runtime.Bool("plain-text") && merged.IsPlainTextMode {
				plainText = true
			}
			templateLargeAttachmentIDs = merged.LargeAttachmentIDs
			templateInlineAttachments = merged.InlineAttachments
			templateSmallAttachments = merged.SmallAttachments
			for _, w := range merged.Warnings {
				fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w)
			}
			inlineCount, largeCount := countAttachmentsByType(tpl.Attachments)
			logTemplateInfo(runtime, "apply.send", map[string]interface{}{
				"mailbox_id":         mailboxID,
				"template_id":        tid,
				"is_plain_text_mode": plainText,
				"attachments_total":  len(tpl.Attachments),
				"inline_count":       inlineCount,
				"large_count":        largeCount,
				"tos_count":          countAddresses(to),
				"ccs_count":          countAddresses(ccFlag),
				"bccs_count":         countAddresses(bccFlag),
			})

			if err := validateComposeHasAtLeastOneRecipient(to, ccFlag, bccFlag); err != nil {
				return err
			}
		}

		sigResult, err := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail)
		if err != nil {
			return err
		}

		bld := emlbuilder.New().WithFileIO(runtime.FileIO()).
			Subject(subject).
			ToAddrs(parseNetAddrs(to))
		if senderEmail != "" {
			bld = bld.From("", senderEmail)
		}
		if err := requireSenderForRequestReceipt(runtime, senderEmail); err != nil {
			return err
		}
		if runtime.Bool("request-receipt") {
			bld = bld.DispositionNotificationTo("", senderEmail)
		}
		if ccFlag != "" {
			bld = bld.CCAddrs(parseNetAddrs(ccFlag))
		}
		if bccFlag != "" {
			bld = bld.BCCAddrs(parseNetAddrs(bccFlag))
		}
		inlineSpecs, err := parseInlineSpecs(inlineFlag)
		if err != nil {
			return err
		}
		var autoResolvedPaths []string
		var composedHTMLBody string
		var composedTextBody string
		if plainText {
			composedTextBody = body
			bld = bld.TextBody([]byte(composedTextBody))
		} else if bodyIsHTML(body) || sigResult != nil {

			htmlBody := body
			if !bodyIsHTML(body) {
				htmlBody = buildBodyDiv(body, false)
			}
			resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(htmlBody)
			if resolveErr != nil {
				return resolveErr
			}
			resolved = injectSignatureIntoBody(resolved, sigResult)
			composedHTMLBody = resolved
			bld = bld.HTMLBody([]byte(composedHTMLBody))
			bld = addSignatureImagesToBuilder(bld, sigResult)
			var allCIDs []string
			for _, ref := range refs {
				bld = bld.AddFileInline(ref.FilePath, ref.CID)
				autoResolvedPaths = append(autoResolvedPaths, ref.FilePath)
				allCIDs = append(allCIDs, ref.CID)
			}
			for _, spec := range inlineSpecs {
				bld = bld.AddFileInline(spec.FilePath, spec.CID)
				allCIDs = append(allCIDs, spec.CID)
			}
			allCIDs = append(allCIDs, signatureCIDs(sigResult)...)
			var tplInlineCIDs []string
			bld, tplInlineCIDs, err = embedTemplateInlineAttachments(ctx, runtime, bld, resolved, mailboxID, templateID, templateInlineAttachments)
			if err != nil {
				return err
			}
			allCIDs = append(allCIDs, tplInlineCIDs...)
			if err := validateInlineCIDs(resolved, allCIDs, nil); err != nil {
				return err
			}
		} else {
			composedTextBody = body
			bld = bld.TextBody([]byte(composedTextBody))
		}
		// Embed template SMALL non-inline attachments via AddAttachment.
		// Runs after the body branch so the part list is already set; the
		// call is a no-op when the template contributes no SMALL entries.
		var templateSmallBytes int64
		bld, templateSmallBytes, err = embedTemplateSmallAttachments(ctx, runtime, bld, mailboxID, templateID, templateSmallAttachments)
		if err != nil {
			return err
		}
		bld = applyPriority(bld, priority)
		allInlinePaths := append(inlineSpecFilePaths(inlineSpecs), autoResolvedPaths...)
		composedBodySize := int64(len(composedHTMLBody) + len(composedTextBody))
		emlBase := estimateEMLBaseSize(runtime.FileIO(), composedBodySize, allInlinePaths, 0) + templateSmallBytes
		bld, err = processLargeAttachments(ctx, runtime, bld, composedHTMLBody, composedTextBody, splitByComma(attachFlag), emlBase, 0)
		if err != nil {
			return err
		}

		if hdr, hdrErr := encodeTemplateLargeAttachmentHeader(templateLargeAttachmentIDs); hdrErr == nil && hdr != "" {
			bld = bld.Header(draftpkg.LargeAttachmentIDsHeader, hdr)
		}

		rawEML, err := bld.BuildBase64URL()
		if err != nil {
			return fmt.Errorf("failed to build EML: %w", err)
		}

		draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
		if err != nil {
			return fmt.Errorf("failed to create draft: %w", err)
		}
		if !confirmSend {
			runtime.Out(buildDraftSavedOutput(draftResult, mailboxID), nil)
			hintSendDraft(runtime, mailboxID, draftResult.DraftID)
			return nil
		}
		resData, err := draftpkg.Send(runtime, mailboxID, draftResult.DraftID, sendTime)
		if err != nil {
			return fmt.Errorf("failed to send email (draft %s created but not sent): %w", draftResult.DraftID, err)
		}
		runtime.Out(buildDraftSendOutput(resData, mailboxID), nil)
		return nil
	},
}

MailSend is the `+send` shortcut: compose a new email and save it as a draft by default (or send immediately with --confirm-send).

View Source
var MailSendReceipt = common.Shortcut{
	Service:     "mail",
	Command:     "+send-receipt",
	Description: "Send a read-receipt reply for an incoming message that requested one (i.e. carries the READ_RECEIPT_REQUEST label). Body is auto-generated (subject / recipient / send time / read time) to match the Lark client's receipt format — callers cannot customize it, matching the industry norm that read-receipt bodies are system-generated templates, not free-form replies. Intended for agent use after the user confirms.",
	Risk:        "high-risk-write",
	Scopes: []string{
		"mail:user_mailbox.message:send",
		"mail:user_mailbox.message:modify",
		"mail:user_mailbox.message:readonly",
		"mail:user_mailbox:readonly",
		"mail:user_mailbox.message.address:read",
		"mail:user_mailbox.message.subject:read",

		"mail:user_mailbox.message.body:read",
	},
	AuthTypes: []string{"user"},
	Flags: []common.Flag{
		{Name: "message-id", Desc: "Required. Message ID of the incoming mail that requested a read receipt.", Required: true},
		{Name: "mailbox", Desc: "Mailbox email address that owns the receipt reply (default: me)."},
		{Name: "from", Desc: "Sender email address for the From header. Defaults to the mailbox's primary address."},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		messageID := runtime.Str("message-id")
		mailboxID := resolveComposeMailboxID(runtime)
		return common.NewDryRunAPI().
			Desc("Send read receipt: fetch the original message → verify the READ_RECEIPT_REQUEST label is present → build a reply with subject \"已读回执:<original>\" (zh) or \"Read receipt: <original>\" (en) picked by CJK detection on the original subject, In-Reply-To / References threading, and X-Lark-Read-Receipt-Mail: 1 → create draft and send. The backend extracts the private header, sets BodyExtra.IsReadReceiptMail, and DraftSend applies the READ_RECEIPT_SENT label to the outgoing message.").
			GET(mailboxPath(mailboxID, "messages", messageID)).
			Params(map[string]interface{}{"format": messageGetFormat(false)}).
			GET(mailboxPath(mailboxID, "profile")).
			POST(mailboxPath(mailboxID, "drafts")).
			Body(map[string]interface{}{"raw": "<base64url-EML>"}).
			POST(mailboxPath(mailboxID, "drafts", "<draft_id>", "send"))
	},

	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		messageID := runtime.Str("message-id")

		mailboxID := resolveComposeMailboxID(runtime)

		msg, err := fetchFullMessage(runtime, mailboxID, messageID, false)
		if err != nil {
			return fmt.Errorf("failed to fetch original message: %w", err)
		}
		if !hasReadReceiptRequestLabel(msg) {
			return fmt.Errorf("message %s did not request a read receipt (no %s label); refusing to send receipt", messageID, readReceiptRequestLabel)
		}

		origSubject := strVal(msg["subject"])
		origSMTPID := normalizeMessageID(strVal(msg["smtp_message_id"]))
		origFromEmail, _ := extractAddressPair(msg["head_from"])
		origReferences := joinReferences(msg["references"])
		origSendMillis := parseInternalDateMillis(msg["internal_date"])

		if origFromEmail == "" {
			return fmt.Errorf("original message %s has no sender address; cannot address receipt", messageID)
		}

		senderEmail := resolveComposeSenderEmail(runtime)
		if senderEmail == "" {
			return fmt.Errorf("unable to determine sender email; please specify --from explicitly")
		}

		lang := detectSubjectLang(origSubject)
		readTime := time.Now()
		textBody := buildReceiptTextBody(lang, origSubject, senderEmail, origSendMillis, readTime)
		htmlBody := buildReceiptHTMLBody(lang, origSubject, senderEmail, origSendMillis, readTime)

		bld := emlbuilder.New().WithFileIO(runtime.FileIO()).
			Subject(buildReceiptSubject(origSubject)).
			From("", senderEmail).
			To("", origFromEmail).
			TextBody([]byte(textBody)).
			HTMLBody([]byte(htmlBody)).
			IsReadReceiptMail(true)
		if origSMTPID != "" {
			bld = bld.InReplyTo(origSMTPID)
		}
		if refs := buildReceiptReferences(origReferences, origSMTPID); refs != "" {
			bld = bld.References(refs)
		}
		if messageID != "" {
			bld = bld.LMSReplyToMessageID(messageID)
		}

		rawEML, err := bld.BuildBase64URL()
		if err != nil {
			return fmt.Errorf("failed to build receipt EML: %w", err)
		}

		draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
		if err != nil {
			return fmt.Errorf("failed to create receipt draft: %w", err)
		}
		resData, err := draftpkg.Send(runtime, mailboxID, draftResult.DraftID, "")
		if err != nil {
			return fmt.Errorf("failed to send receipt (draft %s created but not sent): %w", draftResult.DraftID, err)
		}

		out := buildDraftSendOutput(resData, mailboxID)
		out["receipt_for_message_id"] = messageID
		runtime.OutFormat(out, nil, func(w io.Writer) {
			fmt.Fprintln(w, "已对原邮件发送回执 / Read receipt sent.")
			fmt.Fprintf(w, "receipt_for_message_id: %s\n", messageID)
		})
		return nil
	},
}

MailSendReceipt is the `+send-receipt` shortcut: send an auto-generated read-receipt reply (RFC 3798 MDN) for an incoming message that carries the READ_RECEIPT_REQUEST label. Risk is "high-risk-write"; callers must pass --yes.

View Source
var MailShareToChat = common.Shortcut{
	Service:     "mail",
	Command:     "+share-to-chat",
	Description: "Share an email or thread as a card to a Lark IM chat.",
	Risk:        "write",
	Scopes: []string{
		"mail:user_mailbox.message:readonly",
		"im:message",
		"im:message.send_as_user",
	},
	AuthTypes: []string{"user"},
	HasFormat: true,
	Flags: []common.Flag{
		{Name: "message-id", Desc: "Message ID to share (mutually exclusive with --thread-id)"},
		{Name: "thread-id", Desc: "Thread ID to share (mutually exclusive with --message-id)"},
		{Name: "receive-id", Desc: "Receiver ID. Type determined by --receive-id-type.", Required: true},
		{Name: "receive-id-type", Default: "chat_id", Desc: "Receiver ID type: chat_id (default), open_id, user_id, union_id, email"},
		{Name: "mailbox", Default: "me", Desc: "Mailbox email address (default: me)"},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		mailboxID := resolveMailboxID(runtime)
		msgID := runtime.Str("message-id")
		threadID := runtime.Str("thread-id")
		receiveID := runtime.Str("receive-id")
		receiveIDType := runtime.Str("receive-id-type")

		var createBody map[string]interface{}
		if threadID != "" {
			createBody = map[string]interface{}{"thread_id": threadID}
		} else {
			createBody = map[string]interface{}{"message_id": msgID}
		}

		return common.NewDryRunAPI().
			Desc("Share email card: create share token → send card to IM chat").
			POST(mailboxPath(mailboxID, "messages", "share_token")).
			Body(createBody).
			POST(mailboxPath(mailboxID, "share_tokens", "<card_id>", "send")).
			Params(map[string]interface{}{"receive_id_type": receiveIDType}).
			Body(map[string]interface{}{"receive_id": receiveID})
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		msgID := runtime.Str("message-id")
		threadID := runtime.Str("thread-id")
		if msgID == "" && threadID == "" {
			return output.ErrValidation("either --message-id or --thread-id is required")
		}
		if msgID != "" && threadID != "" {
			return output.ErrValidation("--message-id and --thread-id are mutually exclusive")
		}
		idType := runtime.Str("receive-id-type")
		if !validReceiveIDTypes[idType] {
			return output.ErrValidation("--receive-id-type must be one of: chat_id, open_id, user_id, union_id, email")
		}
		return nil
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		msgID := runtime.Str("message-id")
		threadID := runtime.Str("thread-id")
		receiveID := runtime.Str("receive-id")
		receiveIDType := runtime.Str("receive-id-type")
		mailboxID := resolveMailboxID(runtime)

		var createBody map[string]interface{}
		if threadID != "" {
			createBody = map[string]interface{}{"thread_id": threadID}
		} else {
			createBody = map[string]interface{}{"message_id": msgID}
		}
		createResp, err := runtime.CallAPI("POST",
			mailboxPath(mailboxID, "messages", "share_token"),
			nil, createBody)
		if err != nil {
			return fmt.Errorf("create share token: %w", err)
		}
		cardID, _ := createResp["card_id"].(string)
		if cardID == "" {
			return fmt.Errorf("create share token: response missing card_id")
		}

		sendResp, err := runtime.CallAPI("POST",
			mailboxPath(mailboxID, "share_tokens", cardID, "send"),
			map[string]interface{}{"receive_id_type": receiveIDType},
			map[string]interface{}{"receive_id": receiveID})
		if err != nil {
			return fmt.Errorf("share token created (card_id=%s) but send failed: %w", cardID, err)
		}

		runtime.Out(map[string]interface{}{
			"card_id":       cardID,
			"im_message_id": sendResp["message_id"],
		}, nil)
		return nil
	},
}

MailShareToChat shares an email or thread as a card to a Lark IM chat.

View Source
var MailSignature = common.Shortcut{
	Service:     "mail",
	Command:     "+signature",
	Description: "List or view email signatures with default usage info.",
	Risk:        "read",
	Scopes:      []string{"mail:user_mailbox:readonly"},
	AuthTypes:   []string{"user"},
	HasFormat:   true,
	Flags: []common.Flag{
		{Name: "from", Default: "me", Desc: "Mailbox address (default: me)"},
		{Name: "detail", Desc: "Signature ID to view rendered details. Omit to list all signatures."},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		mailboxID := runtime.Str("from")
		if mailboxID == "" {
			mailboxID = "me"
		}
		return common.NewDryRunAPI().
			Desc("List or view email signatures").
			GET(mailboxPath(mailboxID, "signatures"))
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		mailboxID := runtime.Str("from")
		if mailboxID == "" {
			mailboxID = "me"
		}
		detailID := runtime.Str("detail")

		resp, err := signature.ListAll(runtime, mailboxID)
		if err != nil {
			return err
		}

		if detailID != "" {
			return executeSignatureDetail(runtime, resp, detailID, mailboxID)
		}
		return executeSignatureList(runtime, resp)
	},
}
View Source
var MailTemplateCreate = common.Shortcut{
	Service:     "mail",
	Command:     "+template-create",
	Description: "Create a personal mail template. Scans HTML <img src> local paths (reusing draft inline-image detection), uploads inline images and non-inline attachments to Drive, rewrites HTML to cid: references, and POSTs a Template payload to mail.user_mailbox.templates.create.",
	Risk:        "write",
	Scopes:      []string{"mail:user_mailbox.message:modify", "mail:user_mailbox:readonly"},
	AuthTypes:   []string{"user", "bot"},
	HasFormat:   true,
	Flags: []common.Flag{
		{Name: "mailbox", Desc: "Mailbox email address that owns the template (default: me)."},
		{Name: "name", Desc: "Required. Template name (≤100 chars).", Required: true},
		{Name: "subject", Desc: "Optional. Default subject saved with the template."},
		{Name: "template-content", Desc: "Template body content. Prefer HTML. Referenced local images (<img src=\"./file.png\">) are auto-uploaded to Drive and rewritten to cid: refs."},
		{Name: "template-content-file", Desc: "Optional. Path to a file whose contents become --template-content. Relative path only. Mutually exclusive with --template-content."},
		{Name: "plain-text", Type: "bool", Desc: "Mark the template as plain-text mode (is_plain_text_mode=true). Inline images still require HTML content; use only for pure plain-text templates."},
		{Name: "to", Desc: "Optional. Default To recipient list. Separate multiple addresses with commas. Display-name format is supported."},
		{Name: "cc", Desc: "Optional. Default Cc recipient list. Separate multiple addresses with commas."},
		{Name: "bcc", Desc: "Optional. Default Bcc recipient list. Separate multiple addresses with commas."},
		{Name: "attach", Desc: "Optional. Non-inline attachment file path(s), comma-separated (relative path only). Each file is uploaded to Drive; the order follows the flag order exactly (order-sensitive for LARGE/SMALL classification)."},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		mailboxID := resolveComposeMailboxID(runtime)
		content, _, rcErr := resolveTemplateContent(runtime)
		if rcErr != nil {
			fmt.Fprintf(runtime.IO().ErrOut, "warning: dry-run could not load template content: %v\n", rcErr)
		}
		logTemplateInfo(runtime, "create.dry_run", map[string]interface{}{
			"mailbox_id":         mailboxID,
			"is_plain_text_mode": runtime.Bool("plain-text"),
			"name_len":           len([]rune(runtime.Str("name"))),
			"attachments_total":  len(splitByComma(runtime.Str("attach"))) + len(parseLocalImgs(content)),
			"inline_count":       len(parseLocalImgs(content)),
			"tos_count":          countAddresses(runtime.Str("to")),
			"ccs_count":          countAddresses(runtime.Str("cc")),
			"bccs_count":         countAddresses(runtime.Str("bcc")),
		})
		api := common.NewDryRunAPI().
			Desc("Create a new mail template. The command scans HTML for local <img src> references, uploads each inline image to Drive (≤20MB single upload_all; >20MB upload_prepare+upload_part+upload_finish), rewrites <img src> values to cid: references, uploads any non-inline --attach files the same way, and finally POSTs a Template payload to mail.user_mailbox.templates.create.")

		for _, img := range parseLocalImgs(content) {
			addTemplateUploadSteps(runtime, api, img.Path)
		}
		for _, p := range splitByComma(runtime.Str("attach")) {
			addTemplateUploadSteps(runtime, api, p)
		}
		api = api.POST(templateMailboxPath(mailboxID)).
			Body(map[string]interface{}{
				"template": map[string]interface{}{
					"name":               runtime.Str("name"),
					"subject":            runtime.Str("subject"),
					"template_content":   "<rewritten-HTML-or-text>",
					"is_plain_text_mode": runtime.Bool("plain-text"),
					"tos":                renderTemplateAddresses(runtime.Str("to")),
					"ccs":                renderTemplateAddresses(runtime.Str("cc")),
					"bccs":               renderTemplateAddresses(runtime.Str("bcc")),
					"attachments":        "<computed from uploads>",
				},
			})
		return api
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		if strings.TrimSpace(runtime.Str("name")) == "" {
			return output.ErrValidation("--name is required")
		}
		if len([]rune(runtime.Str("name"))) > 100 {
			return output.ErrValidation("--name must be at most 100 characters")
		}
		if runtime.Str("template-content") != "" && runtime.Str("template-content-file") != "" {
			return output.ErrValidation("--template-content and --template-content-file are mutually exclusive")
		}
		return nil
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		mailboxID := resolveComposeMailboxID(runtime)
		content, _, err := resolveTemplateContent(runtime)
		if err != nil {
			return err
		}
		name := runtime.Str("name")
		subject := runtime.Str("subject")
		isPlainText := runtime.Bool("plain-text")
		tos := renderTemplateAddresses(runtime.Str("to"))
		ccs := renderTemplateAddresses(runtime.Str("cc"))
		bccs := renderTemplateAddresses(runtime.Str("bcc"))

		content = wrapTemplateContentIfNeeded(content, isPlainText)
		if int64(len(content)) > maxTemplateContentBytes {
			return output.ErrValidation("template content exceeds %d MB (got %.1f MB)",
				maxTemplateContentBytes/(1024*1024),
				float64(len(content))/1024/1024)
		}

		rewritten, atts, err := buildTemplatePayloadFromFlags(
			ctx, runtime, name, subject, content, tos, ccs, bccs,
			splitByComma(runtime.Str("attach")),
		)
		if err != nil {
			return err
		}
		inlineCount, largeCount := countAttachmentsByType(atts)
		logTemplateInfo(runtime, "create.execute", map[string]interface{}{
			"mailbox_id":         mailboxID,
			"is_plain_text_mode": isPlainText,
			"name_len":           len([]rune(name)),
			"attachments_total":  len(atts),
			"inline_count":       inlineCount,
			"large_count":        largeCount,
			"tos_count":          len(tos),
			"ccs_count":          len(ccs),
			"bccs_count":         len(bccs),
		})

		payload := &templatePayload{
			Name:            name,
			Subject:         subject,
			TemplateContent: rewritten,
			IsPlainTextMode: isPlainText,
			Tos:             tos,
			Ccs:             ccs,
			Bccs:            bccs,
			Attachments:     atts,
		}

		resp, err := createTemplate(runtime, mailboxID, payload)
		if err != nil {
			return fmt.Errorf("create template failed: %w", err)
		}
		tpl, _ := extractTemplatePayload(resp)
		out := map[string]interface{}{
			"template": tpl,
		}
		runtime.OutFormat(out, nil, func(w io.Writer) {
			fmt.Fprintln(w, "Template created.")
			if tpl != nil {
				fmt.Fprintf(w, "template_id: %s\n", tpl.TemplateID)
				fmt.Fprintf(w, "name: %s\n", tpl.Name)
				fmt.Fprintf(w, "attachments: %d\n", len(tpl.Attachments))
			}
		})
		return nil
	},
}
View Source
var MailTemplateUpdate = common.Shortcut{
	Service:     "mail",
	Command:     "+template-update",
	Description: "Update an existing mail template. Supports --inspect (read-only projection), --print-patch-template (prints a JSON skeleton for --patch-file), and flat flags (--set-subject / --set-name / etc). Internally it GETs the template, applies the patch, rewrites <img> local paths to cid: refs, and PUTs a full-replace update (no optimistic locking: last-write-wins).",
	Risk:        "write",
	Scopes:      []string{"mail:user_mailbox.message:modify", "mail:user_mailbox:readonly"},
	AuthTypes:   []string{"user", "bot"},
	HasFormat:   true,
	Flags: []common.Flag{
		{Name: "mailbox", Desc: "Mailbox email address that owns the template (default: me)."},
		{Name: "template-id", Desc: "Template ID to update. Required except when using --print-patch-template by itself.", Required: false},
		{Name: "inspect", Type: "bool", Desc: "Inspect the template without modifying it. Returns the current template projection (name/subject/addresses/attachments). No write is performed."},
		{Name: "print-patch-template", Type: "bool", Desc: "Print a JSON template describing the supported --patch-file structure. No network call is made."},
		{Name: "patch-file", Desc: "Path to a JSON patch file (relative path only). Shape is the same as --print-patch-template output."},
		{Name: "set-name", Desc: "Replace the template name (≤100 chars)."},
		{Name: "set-subject", Desc: "Replace the template subject."},
		{Name: "set-template-content", Desc: "Replace the template body content. Prefer HTML for rich formatting."},
		{Name: "set-template-content-file", Desc: "Replace template body content with the contents of a file (relative path only). Mutually exclusive with --set-template-content."},
		{Name: "set-plain-text", Type: "bool", Desc: "Set is_plain_text_mode=true."},
		{Name: "set-to", Desc: "Replace the To recipient list. Separate multiple addresses with commas. Pass --set-to=\"\" to clear the list."},
		{Name: "set-cc", Desc: "Replace the Cc recipient list. Pass --set-cc=\"\" to clear the list."},
		{Name: "set-bcc", Desc: "Replace the Bcc recipient list. Pass --set-bcc=\"\" to clear the list."},
		{Name: "attach", Desc: "Additional non-inline attachment file path(s), comma-separated. Each file is uploaded to Drive and appended to the template's attachments[] in the exact flag order."},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		if runtime.Bool("print-patch-template") {
			return common.NewDryRunAPI().
				Set("mode", "print-patch-template").
				Set("template", buildTemplatePatchSkeleton())
		}
		mailboxID := resolveComposeMailboxID(runtime)
		tid := runtime.Str("template-id")
		if tid == "" {
			return common.NewDryRunAPI().Set("error", "--template-id is required except with --print-patch-template")
		}
		if runtime.Bool("inspect") {
			return common.NewDryRunAPI().
				Desc("Inspect the template without modifying it.").
				GET(templateMailboxPath(mailboxID, tid))
		}
		api := common.NewDryRunAPI().
			Desc("Update an existing mail template: GET the template, apply --set-* / --patch-file / --attach changes, upload any new local <img> references and --attach files to Drive, rewrite HTML to cid: references, and PUT a full-replace payload. The template endpoints have no optimistic locking; concurrent updates are last-write-wins.").
			GET(templateMailboxPath(mailboxID, tid))
		content, _, _ := resolveTemplateUpdateContent(runtime)
		for _, img := range parseLocalImgs(content) {
			addTemplateUploadSteps(runtime, api, img.Path)
		}
		for _, p := range splitByComma(runtime.Str("attach")) {
			addTemplateUploadSteps(runtime, api, p)
		}
		api = api.PUT(templateMailboxPath(mailboxID, tid)).
			Body(map[string]interface{}{
				"template": "<merged from GET + patch flags>",
				"_warning": "No optimistic locking — last write wins.",
			})
		logTemplateInfo(runtime, "update.dry_run", map[string]interface{}{
			"mailbox_id":         mailboxID,
			"template_id":        tid,
			"is_plain_text_mode": runtime.Bool("set-plain-text"),
			"name_len":           len([]rune(runtime.Str("set-name"))),
			"attachments_total":  len(splitByComma(runtime.Str("attach"))) + len(parseLocalImgs(content)),
			"inline_count":       len(parseLocalImgs(content)),
			"tos_count":          countAddresses(runtime.Str("set-to")),
			"ccs_count":          countAddresses(runtime.Str("set-cc")),
			"bccs_count":         countAddresses(runtime.Str("set-bcc")),
		})
		return api
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		if runtime.Bool("print-patch-template") {
			return nil
		}
		if err := validateTemplateID(runtime.Str("template-id")); err != nil {
			return err
		}
		if runtime.Str("template-id") == "" {
			return output.ErrValidation("--template-id is required (or use --print-patch-template to print the patch skeleton)")
		}
		if runtime.Str("set-template-content") != "" && runtime.Str("set-template-content-file") != "" {
			return output.ErrValidation("--set-template-content and --set-template-content-file are mutually exclusive")
		}
		if name := runtime.Str("set-name"); name != "" && len([]rune(name)) > 100 {
			return output.ErrValidation("--set-name must be at most 100 characters")
		}
		return nil
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		if runtime.Bool("print-patch-template") {
			runtime.Out(buildTemplatePatchSkeleton(), nil)
			return nil
		}
		mailboxID := resolveComposeMailboxID(runtime)
		tid := runtime.Str("template-id")

		tpl, err := fetchTemplate(runtime, mailboxID, tid)
		if err != nil {
			return err
		}

		if runtime.Bool("inspect") {
			out := map[string]interface{}{"template": tpl}
			runtime.OutFormat(out, nil, func(w io.Writer) {
				fmt.Fprintln(w, "Template inspection (read-only).")
				if tpl != nil {
					fmt.Fprintf(w, "template_id: %s\n", tpl.TemplateID)
					fmt.Fprintf(w, "name: %s\n", tpl.Name)
					if tpl.Subject != "" {
						fmt.Fprintf(w, "subject: %s\n", tpl.Subject)
					}
					fmt.Fprintf(w, "is_plain_text_mode: %v\n", tpl.IsPlainTextMode)
					fmt.Fprintf(w, "attachments: %d\n", len(tpl.Attachments))
				}
			})
			return nil
		}

		if v := runtime.Str("set-name"); v != "" {
			tpl.Name = v
		}
		if v := runtime.Str("set-subject"); v != "" {
			tpl.Subject = v
		}
		newContent, _, err := resolveTemplateUpdateContent(runtime)
		if err != nil {
			return err
		}
		contentChanged := false
		if newContent != "" {
			tpl.TemplateContent = newContent
			contentChanged = true
		}
		if runtime.Bool("set-plain-text") {
			tpl.IsPlainTextMode = true
		}

		if runtime.Changed("set-to") {
			tpl.Tos = renderTemplateAddresses(runtime.Str("set-to"))
		}
		if runtime.Changed("set-cc") {
			tpl.Ccs = renderTemplateAddresses(runtime.Str("set-cc"))
		}
		if runtime.Changed("set-bcc") {
			tpl.Bccs = renderTemplateAddresses(runtime.Str("set-bcc"))
		}

		if pf := strings.TrimSpace(runtime.Str("patch-file")); pf != "" {
			f, err := runtime.FileIO().Open(pf)
			if err != nil {
				return output.ErrValidation("open --patch-file %s: %v", pf, err)
			}
			buf, readErr := io.ReadAll(f)
			f.Close()
			if readErr != nil {
				return output.ErrValidation("read --patch-file %s: %v", pf, readErr)
			}
			var patch templatePatchFile
			if err := json.Unmarshal(buf, &patch); err != nil {
				return output.ErrValidation("parse --patch-file %s: %v", pf, err)
			}
			if patch.TemplateContent != nil {
				contentChanged = true
			}
			applyTemplatePatchFile(tpl, &patch)
		}

		if contentChanged {
			tpl.TemplateContent = wrapTemplateContentIfNeeded(tpl.TemplateContent, tpl.IsPlainTextMode)
		}
		if int64(len(tpl.TemplateContent)) > maxTemplateContentBytes {
			return output.ErrValidation("template content exceeds %d MB (got %.1f MB)",
				maxTemplateContentBytes/(1024*1024),
				float64(len(tpl.TemplateContent))/1024/1024)
		}

		rewritten, newAtts, err := buildTemplatePayloadFromFlags(
			ctx, runtime, tpl.Name, tpl.Subject, tpl.TemplateContent,
			tpl.Tos, tpl.Ccs, tpl.Bccs,
			splitByComma(runtime.Str("attach")),
		)
		if err != nil {
			return err
		}
		tpl.TemplateContent = rewritten

		if contentChanged {
			kept := tpl.Attachments[:0]
			for _, a := range tpl.Attachments {
				if a.IsInline && a.CID != "" && !strings.Contains(tpl.TemplateContent, "cid:"+a.CID) {
					continue
				}
				kept = append(kept, a)
			}
			tpl.Attachments = kept
		}

		seenAttKey := make(map[string]bool, len(tpl.Attachments))
		attKey := func(a templateAttachment) string { return a.ID + "|" + a.CID }
		for _, a := range tpl.Attachments {
			seenAttKey[attKey(a)] = true
		}
		for _, a := range newAtts {
			if seenAttKey[attKey(a)] {
				continue
			}
			seenAttKey[attKey(a)] = true
			tpl.Attachments = append(tpl.Attachments, a)
		}

		for i := range tpl.Attachments {
			if tpl.Attachments[i].Body == "" {
				tpl.Attachments[i].Body = tpl.Attachments[i].ID
			}
		}

		inlineCount, largeCount := countAttachmentsByType(tpl.Attachments)
		logTemplateInfo(runtime, "update.execute", map[string]interface{}{
			"mailbox_id":         mailboxID,
			"template_id":        tid,
			"is_plain_text_mode": tpl.IsPlainTextMode,
			"name_len":           len([]rune(tpl.Name)),
			"attachments_total":  len(tpl.Attachments),
			"inline_count":       inlineCount,
			"large_count":        largeCount,
			"tos_count":          len(tpl.Tos),
			"ccs_count":          len(tpl.Ccs),
			"bccs_count":         len(tpl.Bccs),
		})

		resp, err := updateTemplate(runtime, mailboxID, tid, tpl)
		if err != nil {
			return fmt.Errorf("update template failed: %w", err)
		}
		updated, _ := extractTemplatePayload(resp)
		out := map[string]interface{}{
			"template": updated,
			"warning":  "Template endpoints have no optimistic locking; concurrent updates are last-write-wins.",
		}
		runtime.OutFormat(out, nil, func(w io.Writer) {
			fmt.Fprintln(w, "Template updated (last-write-wins; concurrent writers may overwrite each other).")
			if updated != nil {
				fmt.Fprintf(w, "template_id: %s\n", updated.TemplateID)
				fmt.Fprintf(w, "name: %s\n", updated.Name)
				fmt.Fprintf(w, "attachments: %d\n", len(updated.Attachments))
			}
		})
		fmt.Fprintln(runtime.IO().ErrOut,
			"warning: template endpoints have no optimistic locking; concurrent updates are last-write-wins.")
		return nil
	},
}
View Source
var MailThread = common.Shortcut{
	Service:     "mail",
	Command:     "+thread",
	Description: "Use when querying a full mail conversation/thread by thread ID. Returns all messages in chronological order, including replies and drafts, with body content and attachments metadata, including inline images.",
	Risk:        "read",
	Scopes:      []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
	AuthTypes:   []string{"user", "bot"},
	HasFormat:   true,
	Flags: []common.Flag{
		{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
		{Name: "thread-id", Desc: "Required. Email thread ID", Required: true},
		{Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"},
		{Name: "include-spam-trash", Type: "bool", Desc: "Also return messages from SPAM and TRASH folders (excluded by default)"},
		{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		mailboxID := resolveMailboxID(runtime)
		threadID := runtime.Str("thread-id")
		params := map[string]interface{}{"format": messageGetFormat(runtime.Bool("html"))}
		if runtime.Bool("include-spam-trash") {
			params["include_spam_trash"] = true
		}
		return common.NewDryRunAPI().
			Desc("Fetch all emails in thread with full body content").
			GET(mailboxPath(mailboxID, "threads", threadID)).
			Params(params)
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		if runtime.Bool("print-output-schema") {
			printMessageOutputSchema(runtime)
			return nil
		}
		mailboxID := resolveMailboxID(runtime)
		hintIdentityFirst(runtime, mailboxID)
		threadID := runtime.Str("thread-id")
		html := runtime.Bool("html")

		params := map[string]interface{}{"format": messageGetFormat(html)}
		if runtime.Bool("include-spam-trash") {
			params["include_spam_trash"] = true
		}
		listData, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "threads", threadID), params, nil)
		if err != nil {
			return fmt.Errorf("failed to get thread: %w", err)
		}
		// New API: data.thread.messages[]; fallback to old API: data.items[].message
		var items []interface{}
		if thread, ok := listData["thread"].(map[string]interface{}); ok {
			items, _ = thread["messages"].([]interface{})
		}
		if len(items) == 0 {
			items, _ = listData["items"].([]interface{})
		}
		if len(items) == 0 {
			runtime.Out(mailThreadOutput{ThreadID: threadID, MessageCount: 0, Messages: []map[string]interface{}{}}, nil)
			return nil
		}

		outs := make([]map[string]interface{}, 0, len(items))
		for _, item := range items {
			envelope, ok := item.(map[string]interface{})
			if !ok {
				continue
			}

			msg := envelope
			if inner, ok := envelope["message"].(map[string]interface{}); ok {
				msg = inner
			}
			outs = append(outs, buildMessageOutput(msg, html))
		}

		messages := sortThreadMessagesByInternalDate(outs)

		runtime.Out(mailThreadOutput{ThreadID: threadID, MessageCount: len(messages), Messages: messages}, nil)
		for _, item := range items {
			envelope, ok := item.(map[string]interface{})
			if !ok {
				continue
			}
			msg := envelope
			if inner, ok := envelope["message"].(map[string]interface{}); ok {
				msg = inner
			}
			maybeHintReadReceiptRequest(runtime, mailboxID, strVal(msg["message_id"]), msg)
		}
		return nil
	},
}

MailThread is the `+thread` shortcut: fetch a full mail conversation by thread ID, returning every message in chronological order.

View Source
var MailTriage = common.Shortcut{
	Service:     "mail",
	Command:     "+triage",
	Description: `List mail summaries (date/from/subject/message_id). Use --query for full-text search, --filter for exact-match conditions.`,
	Risk:        "read",
	Scopes:      []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
	AuthTypes:   []string{"user", "bot"},
	Flags: []common.Flag{
		{Name: "format", Default: "table", Desc: "output format: table | json | data (json/data output object with pagination fields)"},
		{Name: "max", Type: "int", Default: "20", Desc: "maximum number of messages to fetch (1-400; auto-paginates internally)"},
		{Name: "page-size", Type: "int", Desc: "alias for --max"},
		{Name: "page-token", Desc: "pagination token from a previous response to fetch the next page"},
		{Name: "filter", Desc: `exact-match condition filter (JSON). Narrow results by folder, label, sender, recipient, etc. Run --print-filter-schema to see all fields. Example: {"folder":"INBOX","from":["alice@example.com"]}`},
		{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
		{Name: "query", Desc: `full-text keyword search across from/to/subject/body (max 50 chars). Example: "budget report"`},
		{Name: "labels", Type: "bool", Desc: "include label IDs in output"},
		{Name: "print-filter-schema", Type: "bool", Desc: "print --filter field reference and exit"},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		mailbox := resolveMailboxID(runtime)
		query := runtime.Str("query")
		showLabels := runtime.Bool("labels")
		maxCount := resolveTriagePageSize(runtime)
		parsed, parseErr := parseTriagePageToken(runtime.Str("page-token"))
		filter, err := parseTriageFilter(runtime.Str("filter"))
		d := common.NewDryRunAPI().Set("input_filter", runtime.Str("filter"))
		if parseErr != nil {
			return d.Set("filter_error", parseErr.Error())
		}
		if err != nil {
			return d.Set("filter_error", err.Error())
		}
		useSearch, pathErr := resolveTriagePath(parsed, query, filter)
		if pathErr != nil {
			return d.Set("filter_error", pathErr.Error())
		}
		if useSearch {
			resolvedFilter, err := resolveSearchFilter(runtime, mailbox, filter, true)
			if err != nil {
				return d.Set("filter_error", err.Error())
			}
			pageSize := maxCount
			if pageSize > searchPageMax {
				pageSize = searchPageMax
			}
			searchDesc := "search messages (auto-paginates up to --max)"
			if parsed.RawToken != "" {
				searchDesc = "search messages (continues from --page-token, up to --max)"
			}
			searchParams, searchBody, _ := buildSearchParams(runtime, mailbox, query, resolvedFilter, pageSize, parsed.RawToken, true)
			d = d.POST(mailboxPath(mailbox, "search")).
				Params(searchParams).
				Body(searchBody).
				Desc(searchDesc)
			if showLabels {
				d = d.POST(mailboxPath(mailbox, "messages", "batch_get")).
					Body(map[string]interface{}{"format": "metadata", "message_ids": []string{"<message_id>"}}).
					Desc("batch_get messages with format=metadata to populate labels")
			}
			return d
		}
		resolvedFilter, err := resolveListFilter(runtime, mailbox, filter, true)
		if err != nil {
			return d.Set("filter_error", err.Error())
		}
		pageSize := maxCount
		if pageSize > listPageMax {
			pageSize = listPageMax
		}
		listDesc := "list message IDs (auto-paginates up to --max); batch_get with format=metadata"
		if parsed.RawToken != "" {
			listDesc = "list message IDs (continues from --page-token, up to --max); batch_get with format=metadata"
		}
		listParams, _ := buildListParams(runtime, mailbox, resolvedFilter, pageSize, parsed.RawToken, true)
		return d.GET(mailboxPath(mailbox, "messages")).
			Params(listParams).
			POST(mailboxPath(mailbox, "messages", "batch_get")).
			Body(map[string]interface{}{"format": "metadata", "message_ids": []string{"<message_id>"}}).
			Desc(listDesc).
			Set("resolve_note", "name→ID resolution for filter.folder/filter.label runs during execution; dry-run does not call folders/labels list APIs")
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		if runtime.Bool("print-filter-schema") {
			printTriageFilterSchema(runtime)
			return nil
		}
		mailbox := resolveMailboxID(runtime)
		hintIdentityFirst(runtime, mailbox)
		outFormat := runtime.Str("format")
		query := runtime.Str("query")
		if query != "" {
			if err := common.RejectDangerousChars("--query", query); err != nil {
				return err
			}
		}
		showLabels := runtime.Bool("labels")
		filter, err := parseTriageFilter(runtime.Str("filter"))
		if err != nil {
			return err
		}
		maxCount := resolveTriagePageSize(runtime)
		parsed, err := parseTriagePageToken(runtime.Str("page-token"))
		if err != nil {
			return err
		}

		var messages []map[string]interface{}
		var hasMore bool
		var nextPageToken string

		useSearch, err := resolveTriagePath(parsed, query, filter)
		if err != nil {
			return err
		}

		if useSearch {
			resolvedFilter, err := resolveSearchFilter(runtime, mailbox, filter, false)
			if err != nil {
				return err
			}
			pageToken := parsed.RawToken
			for len(messages) < maxCount {
				pageSize := maxCount - len(messages)
				if pageSize > searchPageMax {
					pageSize = searchPageMax
				}
				searchParams, searchBody, err := buildSearchParams(runtime, mailbox, query, resolvedFilter, pageSize, pageToken, false)
				if err != nil {
					return err
				}
				searchData, err := doJSONAPI(runtime, &larkcore.ApiReq{
					HttpMethod:  http.MethodPost,
					ApiPath:     mailboxPath(mailbox, "search"),
					QueryParams: toQueryParams(searchParams),
					Body:        searchBody,
				}, "API call failed")
				if err != nil {
					return err
				}
				pageMessages := buildTriageMessagesFromSearchItems(searchData["items"])
				messages = append(messages, pageMessages...)
				pageHasMore, _ := searchData["has_more"].(bool)
				pageToken, _ = searchData["page_token"].(string)
				if !pageHasMore || pageToken == "" {
					hasMore = false
					nextPageToken = ""
					break
				}
				hasMore = pageHasMore
				nextPageToken = encodeTriagePageToken("search", pageToken)
			}
			if len(messages) > maxCount {
				messages = messages[:maxCount]
			}
			if showLabels && len(messages) > 0 {
				messageIDs := make([]string, len(messages))
				for i, m := range messages {
					messageIDs[i] = strVal(m["message_id"])
				}
				enriched, err := fetchMessageMetas(runtime, mailbox, messageIDs)
				if err != nil {
					return err
				}
				mergeTriageLabels(messages, enriched)
			}
		} else {
			resolvedFilter, err := resolveListFilter(runtime, mailbox, filter, false)
			if err != nil {
				return err
			}
			var (
				messageIDs []string
				pageToken  = parsed.RawToken
			)
			for len(messageIDs) < maxCount {
				pageSize := maxCount - len(messageIDs)
				if pageSize > listPageMax {
					pageSize = listPageMax
				}
				listParams, err := buildListParams(runtime, mailbox, resolvedFilter, pageSize, pageToken, false)
				if err != nil {
					return err
				}
				listData, err := doJSONAPI(runtime, &larkcore.ApiReq{
					HttpMethod:  http.MethodGet,
					ApiPath:     mailboxPath(mailbox, "messages"),
					QueryParams: toQueryParams(listParams),
				}, "API call failed")
				if err != nil {
					return err
				}
				ids := extractTriageMessageIDs(listData["items"])
				messageIDs = append(messageIDs, ids...)
				pageHasMore, _ := listData["has_more"].(bool)
				pageToken, _ = listData["page_token"].(string)
				if !pageHasMore || pageToken == "" {
					hasMore = false
					nextPageToken = ""
					break
				}
				hasMore = pageHasMore
				nextPageToken = encodeTriagePageToken("list", pageToken)
			}
			if len(messageIDs) > maxCount {
				messageIDs = messageIDs[:maxCount]
			}
			messages, err = fetchMessageMetas(runtime, mailbox, messageIDs)
			if err != nil {
				return err
			}
		}

		if messages == nil {
			messages = []map[string]interface{}{}
		}

		switch outFormat {
		case "json", "data":
			outData := map[string]interface{}{
				"messages":   messages,
				"count":      len(messages),
				"has_more":   hasMore,
				"page_token": nextPageToken,
			}
			output.PrintJson(runtime.IO().Out, outData)
		default:
			if len(messages) == 0 {
				fmt.Fprintln(runtime.IO().ErrOut, "No messages found.")
				return nil
			}
			var rows []map[string]interface{}
			for _, msg := range messages {
				row := map[string]interface{}{
					"date":       sanitizeForTerminal(strVal(msg["date"])),
					"from":       sanitizeForTerminal(strVal(msg["from"])),
					"subject":    sanitizeForTerminal(strVal(msg["subject"])),
					"message_id": msg["message_id"],
				}
				if showLabels {
					row["labels"] = msg["labels"]
				}
				rows = append(rows, row)
			}
			output.PrintTable(runtime.IO().Out, rows)
			fmt.Fprintf(runtime.IO().ErrOut, "\n%d message(s)\n", len(messages))
			if hasMore && nextPageToken != "" {
				var hint strings.Builder
				hint.WriteString("next page: mail +triage")
				if query != "" {
					hint.WriteString(" --query " + shellQuote(query))
				}
				if filterStr := runtime.Str("filter"); filterStr != "" {
					hint.WriteString(" --filter " + shellQuote(filterStr))
				}
				hint.WriteString(" --page-token " + shellQuote(nextPageToken))
				fmt.Fprintln(runtime.IO().ErrOut, hint.String())
			}
			fmt.Fprintln(runtime.IO().ErrOut, "tip: use mail +message --message-id <id> to read full content")
		}
		return nil
	},
}
View Source
var MailWatch = common.Shortcut{
	Service:     "mail",
	Command:     "+watch",
	Description: "Watch for incoming mail events via WebSocket (requires scope mail:event and bot event mail.user_mailbox.event.message_received_v1 added). Run with --print-output-schema to see per-format field reference before parsing output.",
	Risk:        "read",
	Scopes:      []string{"mail:event", "mail:user_mailbox.event.mail_address:read", "mail:user_mailbox:readonly", "mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
	AuthTypes:   []string{"user"},
	Flags: []common.Flag{
		{Name: "format", Default: "data", Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"},
		{Name: "msg-format", Default: "metadata", Desc: "message payload mode: metadata(headers + meta, for triage/notification) | minimal(IDs and state only, no headers, for tracking read/folder changes) | plain_text_full(all metadata fields + full plain-text body) | event(raw WebSocket event, no API call, for debug) | full(full message including HTML body and attachments)"},
		{Name: "output-dir", Desc: "Write each message as a JSON file (always full payload, regardless of --msg-format)"},
		{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
		{Name: "labels", Desc: "filter: label names JSON array, e.g. [\"important\",\"team-label\"]"},
		{Name: "folders", Desc: "filter: folder names JSON array, e.g. [\"inbox\",\"news\"]"},
		{Name: "label-ids", Desc: "filter: label IDs JSON array, e.g. [\"FLAGGED\",\"IMPORTANT\"]"},
		{Name: "folder-ids", Desc: "filter: folder IDs JSON array, e.g. [\"INBOX\",\"SENT\"]"},
		{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference per --msg-format (run this first to learn field names before parsing output)"},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		mailbox := resolveMailboxID(runtime)
		msgFormat := runtime.Str("msg-format")
		labelIDsInput := runtime.Str("label-ids")
		folderIDsInput := runtime.Str("folder-ids")
		labelsInput := runtime.Str("labels")
		foldersInput := runtime.Str("folders")
		outputDir := runtime.Str("output-dir")

		resolvedFolderIDs, folderDeferred := resolveWatchFilterIDsForDryRun(folderIDsInput, foldersInput, false, resolveFolderSystemAliasOrID)
		resolvedLabelIDs, labelDeferred := resolveWatchFilterIDsForDryRun(labelIDsInput, labelsInput, false, resolveLabelSystemID)

		outputDirDisplay := "(stdout)"
		if outputDir != "" {
			outputDirDisplay = outputDir
		}
		effectiveFolderDisplay := strings.Join(resolvedFolderIDs, ",")
		if effectiveFolderDisplay == "" {
			effectiveFolderDisplay = "(none)"
		}
		effectiveLabelDisplay := strings.Join(resolvedLabelIDs, ",")
		if effectiveLabelDisplay == "" {
			effectiveLabelDisplay = "(none)"
		}

		dryRunDesc := "Step 1: subscribe mailbox events; Step 2: watch via WebSocket (long-running)"
		if folderDeferred || labelDeferred {
			dryRunDesc += "; non-system folder/label names are resolved to IDs during execution"
		}
		d := common.NewDryRunAPI().
			Desc(dryRunDesc).
			Set("command", "mail +watch").
			Set("app_id", runtime.Config.AppID).
			Set("msg_format", msgFormat).
			Set("output_dir", outputDirDisplay).
			Set("input_folder_ids", folderIDsInput).
			Set("input_folders", foldersInput).
			Set("input_label_ids", labelIDsInput).
			Set("input_labels", labelsInput).
			Set("effective_folder_ids", resolvedFolderIDs).
			Set("effective_label_ids", resolvedLabelIDs)

		d.POST(mailboxPath(mailbox, "event", "subscribe")).
			Desc(fmt.Sprintf("Subscribe mailbox events (effective_folder_ids=%s, effective_label_ids=%s)", effectiveFolderDisplay, effectiveLabelDisplay)).
			Body(map[string]interface{}{"event_type": 1})

		if mailbox == "me" {
			d.GET(mailboxPath("me", "profile")).
				Desc("Resolve mailbox address for event filtering (requires scope mail:user_mailbox:readonly)")
		}

		if len(resolvedLabelIDs) > 0 {
			d.Set("filter_label_ids", strings.Join(resolvedLabelIDs, ","))
		}
		if len(resolvedFolderIDs) > 0 {
			d.Set("filter_folder_ids", strings.Join(resolvedFolderIDs, ","))
		}

		if msgFormat != "event" || len(resolvedLabelIDs) > 0 || len(resolvedFolderIDs) > 0 {
			params := map[string]interface{}{
				"format": watchFetchFormat(msgFormat, len(resolvedLabelIDs) > 0 || len(resolvedFolderIDs) > 0),
			}
			d.GET(mailboxPath(mailbox, "messages", "{message_id}")).
				Params(params)
		}
		return d
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		if runtime.Bool("print-output-schema") {
			printWatchOutputSchema(runtime)
			return nil
		}
		mailbox := resolveMailboxID(runtime)
		hintIdentityFirst(runtime, mailbox)
		outFormat := runtime.Str("format")
		switch outFormat {
		case "json", "data", "":
		default:
			return output.ErrValidation("invalid --format %q: must be json or data", outFormat)
		}
		msgFormat := runtime.Str("msg-format")
		outputDir := runtime.Str("output-dir")
		if outputDir != "" {

			if strings.HasPrefix(outputDir, "~") {
				return output.ErrValidation("--output-dir does not support ~ expansion; use a relative path like ./output instead")
			}

			safePath, err := validate.SafeOutputPath(outputDir)
			if err != nil {
				return err
			}
			outputDir = safePath
			if err := vfs.MkdirAll(outputDir, 0700); err != nil {
				return fmt.Errorf("cannot create output directory %q: %w", outputDir, err)
			}
		}
		labelIDsInput := runtime.Str("label-ids")
		folderIDsInput := runtime.Str("folder-ids")
		labelsInput := runtime.Str("labels")
		foldersInput := runtime.Str("folders")

		errOut := runtime.IO().ErrOut
		out := runtime.IO().Out

		info := func(msg string) {
			fmt.Fprintln(errOut, msg)
		}

		resolvedLabelIDs, err := resolveWatchFilterIDs(runtime, mailbox, labelIDsInput, labelsInput, resolveLabelID, resolveLabelNames, resolveLabelSystemID, "label-ids", "labels", "label")
		if err != nil {
			return err
		}
		resolvedFolderIDs, err := resolveWatchFilterIDs(runtime, mailbox, folderIDsInput, foldersInput, resolveFolderID, resolveFolderNames, resolveFolderSystemAliasOrID, "folder-ids", "folders", "folder")
		if err != nil {
			return err
		}
		labelIDSet := make(map[string]bool, len(resolvedLabelIDs))
		for _, id := range resolvedLabelIDs {
			if id != "" {
				labelIDSet[id] = true
			}
		}
		folderIDSet := make(map[string]bool, len(resolvedFolderIDs))
		for _, id := range resolvedFolderIDs {
			if id != "" {
				folderIDSet[id] = true
			}
		}

		info(fmt.Sprintf("Subscribing mailbox events for: %s", mailbox))
		_, err = runtime.CallAPI("POST", mailboxPath(mailbox, "event", "subscribe"), nil, map[string]interface{}{"event_type": 1})
		if err != nil {
			return wrapWatchSubscribeError(err)
		}
		info("Mailbox subscribed.")

		var unsubOnce sync.Once
		var unsubErr error
		unsubscribe := func() error {
			unsubOnce.Do(func() {
				_, unsubErr = runtime.CallAPI("POST", mailboxPath(mailbox, "event", "unsubscribe"), nil, map[string]interface{}{"event_type": 1})
			})
			return unsubErr
		}
		var unsubLogOnce sync.Once
		unsubscribeWithLog := func() {
			unsubLogOnce.Do(func() {
				info("Unsubscribing mailbox events...")
				if err := unsubscribe(); err != nil {
					fmt.Fprintf(errOut, "Warning: unsubscribe failed: %v\n", err)
				} else {
					info("Mailbox unsubscribed.")
				}
			})
		}
		defer unsubscribeWithLog()

		mailboxFilter := mailbox
		if mailbox == "me" {
			resolved, profileErr := fetchMailboxPrimaryEmail(runtime, "me")
			if profileErr != nil {
				return enhanceProfileError(profileErr)
			}
			mailboxFilter = resolved
		}

		var eventCount atomic.Int64

		handleEvent := func(data map[string]interface{}) {

			eventBody := extractMailEventBody(data)

			if mailboxFilter != "" {
				mailAddr, _ := eventBody["mail_address"].(string)
				if !strings.EqualFold(mailAddr, mailboxFilter) {
					return
				}
			}

			messageID, _ := eventBody["message_id"].(string)
			if messageID == "" {
				return
			}

			fetchMailbox := mailbox
			if eventAddr, _ := eventBody["mail_address"].(string); eventAddr != "" {
				fetchMailbox = eventAddr
			}

			needMessage := msgFormat != "event" || len(labelIDSet) > 0 || len(folderIDSet) > 0 || outputDir != ""
			var message map[string]interface{}
			if needMessage {
				var err error
				fetchFormat := watchFetchFormat(msgFormat, len(labelIDSet) > 0 || len(folderIDSet) > 0)
				if outputDir != "" {
					fetchFormat = "full"
				}
				message, err = fetchMessageForWatch(runtime, fetchMailbox, messageID, fetchFormat)
				if err != nil {
					output.PrintError(errOut, fmt.Sprintf("fetch message %s failed: %v", fetchFormat, err))
					failureData := watchFetchFailureValue(messageID, fetchFormat, err, eventBody)
					if outputDir != "" {
						if _, writeErr := writeMailEventFile(outputDir, failureData, data); writeErr != nil {
							output.PrintError(errOut, fmt.Sprintf("failed to write event file: %v", writeErr))
						}
					}
					output.PrintJson(out, failureData)
					return
				}
			}

			if len(folderIDSet) > 0 {
				folderID, _ := message["folder_id"].(string)
				if !folderIDSet[folderID] {
					return
				}
			}
			if len(labelIDSet) > 0 {
				if !messageHasLabel(message, labelIDSet) {
					return
				}
			}

			eventCount.Add(1)

			if message != nil {
				for _, field := range []string{"body_plain_text", "body_preview", "body_plain"} {
					if body, ok := message[field].(string); ok && body != "" {
						decoded := decodeBase64URL(body)
						if detectPromptInjection(decoded) {
							from, _ := message["from"].(string)
							fmt.Fprintf(errOut, "[SECURITY WARNING] Possible prompt injection detected in message from %s\n", sanitizeForTerminal(from))
						}
						break
					}
				}
			}

			fullMessage := message

			var outputData interface{} = data
			if msgFormat != "event" && message != nil {
				if msgFormat == "minimal" {
					message = minimalWatchMessage(message)
				}
				outputData = map[string]interface{}{"message": message}
			}

			if outputDir != "" {
				_, err := writeMailEventFile(outputDir, decodeBodyFieldsForFile(fullMessage), data)
				if err != nil {
					output.PrintError(errOut, fmt.Sprintf("failed to write event file: %v", err))
				}
			}

			switch outFormat {
			case "json", "":
				output.PrintNdjson(out, output.Envelope{OK: true, Identity: string(runtime.As()), Data: outputData})
			case "data":
				output.PrintNdjson(out, outputData)
			}
		}

		rawHandler := func(ctx context.Context, event *larkevent.EventReq) error {
			var eventData map[string]interface{}
			if event.Body != nil {
				dec := json.NewDecoder(bytes.NewReader(event.Body))
				dec.UseNumber()
				if err := dec.Decode(&eventData); err != nil {
					fmt.Fprintf(errOut, "warning: failed to decode event body: %v\n", err)
				}
			}
			if eventData == nil {
				eventData = make(map[string]interface{})
			}
			handleEvent(eventData)
			return nil
		}

		sdkLogger := &mailWatchLogger{w: errOut}

		eventDispatcher := dispatcher.NewEventDispatcher("", "")
		eventDispatcher.InitConfig(larkevent.WithLogger(sdkLogger))
		eventDispatcher.OnCustomizedEvent(mailEventType, rawHandler)

		endpoints := core.ResolveEndpoints(runtime.Config.Brand)
		domain := endpoints.Open

		info("Connecting to Feishu event WebSocket...")
		info(fmt.Sprintf("Listening for: %s", mailEventType))
		info(fmt.Sprintf("Output mode: %s", msgFormat))
		if mailboxFilter != "" {
			info(fmt.Sprintf("Filter: mailbox=%s", mailboxFilter))
		}
		if len(folderIDSet) > 0 {
			info(fmt.Sprintf("Filter: folder-ids=%s", strings.Join(setKeys(folderIDSet), ",")))
		}
		if len(labelIDSet) > 0 {
			info(fmt.Sprintf("Filter: label-ids=%s", strings.Join(setKeys(labelIDSet), ",")))
		}

		cli := larkws.NewClient(runtime.Config.AppID, runtime.Config.AppSecret,
			larkws.WithEventHandler(eventDispatcher),
			larkws.WithDomain(domain),
			larkws.WithLogger(sdkLogger),
		)

		watchCtx, cancelWatch := context.WithCancel(ctx)
		defer cancelWatch()

		sigCh := make(chan os.Signal, 1)
		signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
		stopSignals := func() { signal.Stop(sigCh) }
		defer stopSignals()

		shutdownBySignal := make(chan struct{})
		var shutdownOnce sync.Once
		triggerShutdown := func() {
			shutdownOnce.Do(func() { close(shutdownBySignal) })
			cancelWatch()
		}
		go func() {
			defer func() {
				if r := recover(); r != nil {
					fmt.Fprintf(errOut, "panic in signal handler: %v\n", r)
					triggerShutdown()
				}
			}()
			select {
			case sig := <-sigCh:
				handleMailWatchSignal(errOut, sig, eventCount.Load(), unsubscribeWithLog, stopSignals, cancelWatch)
				triggerShutdown()
			case <-watchCtx.Done():
				return
			}
		}()

		startErrCh := make(chan error, 1)
		go func() {
			startErrCh <- cli.Start(watchCtx)
		}()

		info("Connected. Waiting for mail events... (Ctrl+C to stop)")
		select {
		case <-shutdownBySignal:
			return nil
		case err := <-startErrCh:
			if err != nil {
				select {
				case <-shutdownBySignal:
					return nil
				default:
				}
				if watchCtx.Err() != nil {
					return nil
				}
				return output.ErrNetwork("WebSocket connection failed: %v", err)
			}
			return nil
		}
	},
}

Functions

func Shortcuts

func Shortcuts() []common.Shortcut

Shortcuts returns all mail shortcuts.

Types

type InlineSpec

type InlineSpec struct {
	CID      string `json:"cid"`
	FilePath string `json:"file_path"`
}

InlineSpec represents one inline image entry from the --inline JSON array. CID must be a valid RFC 2822 content-id (e.g. a random hex string). FilePath is the local path to the image file.

type Mailbox

type Mailbox struct {
	Name  string // display name; empty if not present
	Email string
}

Mailbox is a parsed RFC 2822 address: an optional display name plus an email address. The zero value represents a bare address with no name.

func ParseMailbox

func ParseMailbox(raw string) Mailbox

ParseMailbox parses a single address in any of the following forms:

alice@example.com
Alice Smith <alice@example.com>
"Alice Smith" <alice@example.com>

The function is intentionally total (never returns an error): syntactic validation of the email address is left to the Lark API. Control characters are stripped as a defense against header injection.

func ParseMailboxList

func ParseMailboxList(raw string) []Mailbox

ParseMailboxList splits a comma-separated address list and parses each entry. Entries with an empty email address are silently dropped.

func (Mailbox) String

func (m Mailbox) String() string

String formats the mailbox for an RFC 2822 header value. Non-ASCII display names are encoded using RFC 2047.

Directories

Path Synopsis
Package emlbuilder provides a Lark-API-compatible RFC 2822 EML message builder.
Package emlbuilder provides a Lark-API-compatible RFC 2822 EML message builder.
Package filecheck provides mail attachment file validation utilities shared by the emlbuilder and draft packages.
Package filecheck provides mail attachment file validation utilities shared by the emlbuilder and draft packages.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL