mail

package
v1.0.0 Latest Latest
Warning

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

Go to latest
Published: Mar 28, 2026 License: MIT Imports: 30 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.

Variables

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: "Required. Final draft subject. Pass the full subject you want to appear in the draft.", Required: true},
		{Name: "body", Desc: "Required. Full email body. Prefer HTML for rich formatting (bold, lists, links); plain text is also supported. Body type is auto-detected. Use --plain-text to force plain-text mode.", Required: true},
		{Name: "from", Desc: "Optional. Sender email address; also selects the mailbox to create the draft in. If omitted, the current signed-in user's primary mailbox address is used."},
		{Name: "cc", Desc: "Optional. Full Cc recipient list. Separate multiple addresses with commas. Display-name format is supported."},
		{Name: "bcc", Desc: "Optional. Full Bcc recipient list. Separate multiple addresses with commas. Display-name format is supported."},
		{Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring HTML auto-detection. Cannot be used with --inline."},
		{Name: "attach", Desc: "Optional. Regular attachment file paths. 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\":\"<local-path>\"}. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		input, err := parseDraftCreateInput(runtime)
		if err != nil {
			return common.NewDryRunAPI().Set("error", err.Error())
		}
		mailboxID := resolveComposeMailboxID(runtime)
		return common.NewDryRunAPI().
			Desc("Create a new empty draft without sending it. The command first reads the current mailbox profile to determine the default sender when `--from` is omitted, then builds a complete EML from `to/subject/body` plus any optional cc/bcc/attachment/inline inputs, and finally calls drafts.create. `--body` content type is auto-detected (HTML or plain text); use `--plain-text` to force plain-text mode. For inline images, CIDs can be any unique strings, e.g. random hex. Use the dedicated reply or forward shortcuts for reply-style drafts instead of adding reply-thread headers here.").
			GET(mailboxPath(mailboxID, "profile")).
			POST(mailboxPath(mailboxID, "drafts")).
			Body(map[string]interface{}{
				"raw": "<base64url-EML>",
				"_preview": map[string]interface{}{
					"to":      input.To,
					"subject": input.Subject,
				},
			})
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		if strings.TrimSpace(runtime.Str("subject")) == "" {
			return output.ErrValidation("--subject is required; pass the final email subject")
		}
		if strings.TrimSpace(runtime.Str("body")) == "" {
			return output.ErrValidation("--body is required; pass the full email body")
		}
		if err := validateComposeInlineAndAttachments(runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")); err != nil {
			return err
		}
		return nil
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		input, err := parseDraftCreateInput(runtime)
		if err != nil {
			return err
		}
		rawEML, err := buildRawEMLForDraftCreate(runtime, input)
		if err != nil {
			return err
		}
		mailboxID := resolveComposeMailboxID(runtime)
		draftID, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
		if err != nil {
			return fmt.Errorf("create draft failed: %w", err)
		}
		out := map[string]interface{}{"draft_id": draftID}
		runtime.OutFormat(out, nil, func(w io.Writer) {
			fmt.Fprintln(w, "Draft created.")
			fmt.Fprintf(w, "draft_id: %s\n", draftID)
		})
		return nil
	},
}
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)"},
		{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."},
		{Name: "print-patch-template", Type: "bool", Desc: "Print the JSON template and supported operations for the --patch-file flag. Recommended first step before generating a patch file. No draft read or write is performed."},
		{Name: "inspect", Type: "bool", Desc: "Inspect the draft without modifying it. Returns the draft projection including subject, recipients, body summary, has_quoted_content (whether the draft contains a reply/forward quote block), attachments_summary (with part_id and cid for each attachment), and inline_summary. Run this BEFORE editing body to check has_quoted_content: if true, use set_reply_body in --patch-file to preserve the quote; if false, use set_body."},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		if runtime.Bool("print-patch-template") {
			return common.NewDryRunAPI().
				Set("mode", "print-patch-template").
				Set("template", buildDraftEditPatchTemplate())
		}
		draftID := runtime.Str("draft-id")
		if draftID == "" {
			return common.NewDryRunAPI().Set("error", "--draft-id is required for real draft edits; only --print-patch-template can be used without a draft id")
		}
		mailboxID := resolveComposeMailboxID(runtime)
		if runtime.Bool("inspect") {
			return common.NewDryRunAPI().
				Desc("Inspect a draft without modifying it: fetch the raw EML, parse it into MIME structure, and return the projection (subject, recipients, body, attachments_summary, inline_summary). No write is performed.").
				GET(mailboxPath(mailboxID, "drafts", draftID)).
				Params(map[string]interface{}{"format": "raw"})
		}
		patch, err := buildDraftEditPatch(runtime)
		if err != nil {
			return common.NewDryRunAPI().Set("error", err.Error())
		}
		return common.NewDryRunAPI().
			Desc("Edit an existing draft without sending it: first call drafts.get(format=raw) to fetch the current EML, parse it into MIME structure, apply either direct flags or the typed patch from patch-file, re-serialize the updated draft, and then call drafts.update. This is a minimal-edit pipeline rather than a full rebuild, so unchanged headers, attachments, and MIME subtrees are preserved where possible. Body edits must go through --patch-file using set_body or set_reply_body ops. It also has no optimistic locking, so concurrent edits to the same draft are last-write-wins.").
			GET(mailboxPath(mailboxID, "drafts", draftID)).
			Params(map[string]interface{}{"format": "raw"}).
			PUT(mailboxPath(mailboxID, "drafts", draftID)).
			Body(map[string]interface{}{
				"raw":     "<base64url-EML>",
				"_patch":  patch.Summary(),
				"_notice": "This edit flow has no optimistic locking. If the same draft is changed concurrently, the last writer wins.",
			})
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		if runtime.Bool("print-patch-template") {
			runtime.Out(buildDraftEditPatchTemplate(), nil)
			return nil
		}
		draftID := runtime.Str("draft-id")
		if draftID == "" {
			return output.ErrValidation("--draft-id is required for real draft edits; if you only need a patch template, run with --print-patch-template")
		}
		mailboxID := resolveComposeMailboxID(runtime)
		if runtime.Bool("inspect") {
			return executeDraftInspect(runtime, mailboxID, draftID)
		}
		patch, err := buildDraftEditPatch(runtime)
		if err != nil {
			return err
		}
		rawDraft, err := draftpkg.GetRaw(runtime, mailboxID, draftID)
		if err != nil {
			return fmt.Errorf("read draft raw EML failed: %w", err)
		}
		snapshot, err := draftpkg.Parse(rawDraft)
		if err != nil {
			return output.ErrValidation("parse draft raw EML failed: %v", err)
		}
		if err := draftpkg.Apply(snapshot, patch); err != nil {
			return output.ErrValidation("apply draft patch failed: %v", err)
		}
		serialized, err := draftpkg.Serialize(snapshot)
		if err != nil {
			return output.ErrValidation("serialize draft failed: %v", err)
		}
		if err := draftpkg.UpdateWithRaw(runtime, mailboxID, draftID, serialized); err != nil {
			return fmt.Errorf("update draft failed: %w", err)
		}
		projection := draftpkg.Project(snapshot)
		out := map[string]interface{}{
			"draft_id":   draftID,
			"warning":    "This edit flow has no optimistic locking. If the same draft is changed concurrently, the last writer wins.",
			"projection": projection,
		}
		runtime.OutFormat(out, nil, func(w io.Writer) {
			fmt.Fprintln(w, "Draft updated.")
			fmt.Fprintf(w, "draft_id: %s\n", draftID)
			if projection.Subject != "" {
				fmt.Fprintf(w, "subject: %s\n", sanitizeForTerminal(projection.Subject))
			}
			if recipients := prettyDraftAddresses(projection.To); recipients != "" {
				fmt.Fprintf(w, "to: %s\n", sanitizeForTerminal(recipients))
			}
			if projection.BodyText != "" {
				fmt.Fprintf(w, "body_text: %s\n", sanitizeForTerminal(projection.BodyText))
			}
			if projection.BodyHTMLSummary != "" {
				fmt.Fprintf(w, "body_html_summary: %s\n", sanitizeForTerminal(projection.BodyHTMLSummary))
			}
			if len(projection.AttachmentsSummary) > 0 {
				fmt.Fprintf(w, "attachments: %d\n", len(projection.AttachmentsSummary))
			}
			if len(projection.InlineSummary) > 0 {
				fmt.Fprintf(w, "inline_parts: %d\n", len(projection.InlineSummary))
			}
			if len(projection.Warnings) > 0 {
				fmt.Fprintf(w, "warnings: %s\n", sanitizeForTerminal(strings.Join(projection.Warnings, "; ")))
			}
			fmt.Fprintln(w, "warning: This edit flow has no optimistic locking. If the same draft is changed concurrently, the last writer wins.")
		})
		return nil
	},
}
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: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 to forward", Required: true},
		{Name: "to", Desc: "Recipient email address(es), comma-separated"},
		{Name: "body", Desc: "Body prepended before the forwarded message. Prefer HTML for rich formatting; plain text is also supported. Body type is auto-detected from the forward body and the original message. Use --plain-text to force plain-text mode."},
		{Name: "from", Desc: "Sender address; also selects the mailbox to send from (defaults to the authenticated user's primary mailbox)"},
		{Name: "cc", Desc: "CC email address(es), comma-separated"},
		{Name: "bcc", Desc: "BCC email address(es), comma-separated"},
		{Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring all HTML auto-detection. Cannot be used with --inline."},
		{Name: "attach", Desc: "Attachment file path(s), comma-separated (appended after original attachments)"},
		{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<local-path>\"}. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
		{Name: "confirm-send", Type: "bool", Desc: "Send the forward immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		messageId := runtime.Str("message-id")
		to := runtime.Str("to")
		confirmSend := runtime.Bool("confirm-send")
		mailboxID := resolveComposeMailboxID(runtime)
		desc := "Forward: fetch original message → fetch mailbox profile (default From) → save as draft"
		if confirmSend {
			desc = "Forward (--confirm-send): fetch original message → fetch mailbox profile (default From) → create draft → send draft"
		}
		api := common.NewDryRunAPI().
			Desc(desc).
			GET(mailboxPath(mailboxID, "messages", messageId)).
			GET(mailboxPath(mailboxID, "profile")).
			POST(mailboxPath(mailboxID, "drafts")).
			Body(map[string]interface{}{"raw": "<base64url-EML>", "_to": to})
		if confirmSend {
			api = api.POST(mailboxPath(mailboxID, "drafts", "<draft_id>", "send"))
		}
		return api
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		if runtime.Bool("confirm-send") {
			if err := validateComposeHasAtLeastOneRecipient(runtime.Str("to"), runtime.Str("cc"), runtime.Str("bcc")); err != nil {
				return err
			}
		}
		return validateComposeInlineAndAttachments(runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		messageId := runtime.Str("message-id")
		to := runtime.Str("to")
		body := runtime.Str("body")
		fromFlag := runtime.Str("from")
		ccFlag := runtime.Str("cc")
		bccFlag := runtime.Str("bcc")
		plainText := runtime.Bool("plain-text")
		attachFlag := runtime.Str("attach")
		inlineFlag := runtime.Str("inline")
		confirmSend := runtime.Bool("confirm-send")

		mailboxID := resolveComposeMailboxID(runtime)
		sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
		if err != nil {
			return fmt.Errorf("failed to fetch original message: %w", err)
		}
		if err := validateForwardAttachmentURLs(sourceMsg); err != nil {
			return fmt.Errorf("forward blocked: %w", err)
		}
		orig := sourceMsg.Original

		senderEmail := fromFlag
		if senderEmail == "" {
			senderEmail = fetchCurrentUserEmail(runtime)
			if senderEmail == "" {
				senderEmail = orig.headTo
			}
		}

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

		bld := emlbuilder.New().
			Subject(buildForwardSubject(orig.subject)).
			ToAddrs(parseNetAddrs(to))
		if senderEmail != "" {
			bld = bld.From("", senderEmail)
		}
		if ccFlag != "" {
			bld = bld.CCAddrs(parseNetAddrs(ccFlag))
		}
		if bccFlag != "" {
			bld = bld.BCCAddrs(parseNetAddrs(bccFlag))
		}
		if inReplyTo := normalizeMessageID(orig.smtpMessageId); inReplyTo != "" {
			bld = bld.InReplyTo(inReplyTo)
		}
		if messageId != "" {
			bld = bld.LMSReplyToMessageID(messageId)
		}
		useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw))
		if strings.TrimSpace(inlineFlag) != "" && !useHTML {
			return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
		}
		if useHTML {
			if err := validateInlineImageURLs(sourceMsg); err != nil {
				return fmt.Errorf("forward blocked: %w", err)
			}
			processedBody := buildBodyDiv(body, bodyIsHTML(body))
			bld = bld.HTMLBody([]byte(processedBody + buildForwardQuoteHTML(&orig)))
			bld, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages)
			if err != nil {
				return err
			}
		} else {
			bld = bld.TextBody([]byte(buildForwardedMessage(&orig, body)))
		}
		// Download original attachments and accumulate size for limit check
		type downloadedAtt struct {
			content     []byte
			contentType string
			filename    string
		}
		var origAtts []downloadedAtt
		var origAttBytes int64
		type largeAttID struct {
			ID string `json:"id"`
		}
		var largeAttIDs []largeAttID
		for _, att := range sourceMsg.ForwardAttachments {
			if att.AttachmentType == attachmentTypeLarge {
				largeAttIDs = append(largeAttIDs, largeAttID{ID: att.ID})
				continue
			}
			content, err := downloadAttachmentContent(runtime, att.DownloadURL)
			if err != nil {
				return fmt.Errorf("failed to download original attachment %s: %w", att.Filename, err)
			}
			contentType := att.ContentType
			if contentType == "" {
				contentType = "application/octet-stream"
			}
			origAtts = append(origAtts, downloadedAtt{content, contentType, att.Filename})
			origAttBytes += int64(len(content))
		}
		if len(largeAttIDs) > 0 {
			idsJSON, err := json.Marshal(largeAttIDs)
			if err != nil {
				return fmt.Errorf("failed to encode large attachment IDs: %w", err)
			}
			bld = bld.Header("X-Lms-Large-Attachment-Ids", base64.StdEncoding.EncodeToString(idsJSON))
		}
		inlineSpecs, err := parseInlineSpecs(inlineFlag)
		if err != nil {
			return err
		}
		if err := checkAttachmentSizeLimit(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), origAttBytes, len(origAtts)); err != nil {
			return err
		}
		for _, att := range origAtts {
			bld = bld.AddAttachment(att.content, att.contentType, att.filename)
		}
		for _, path := range splitByComma(attachFlag) {
			bld = bld.AddFileAttachment(path)
		}
		for _, spec := range inlineSpecs {
			bld = bld.AddFileInline(spec.FilePath, spec.CID)
		}
		rawEML, err := bld.BuildBase64URL()
		if err != nil {
			return fmt.Errorf("failed to build EML: %w", err)
		}

		draftID, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
		if err != nil {
			return fmt.Errorf("failed to create draft: %w", err)
		}
		if !confirmSend {
			runtime.Out(map[string]interface{}{
				"draft_id": draftID,
				"tip":      fmt.Sprintf(`draft saved. To send: lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"%s","draft_id":"%s"}'`, mailboxID, draftID),
			}, nil)
			hintSendDraft(runtime, mailboxID, draftID)
			return nil
		}
		resData, err := draftpkg.Send(runtime, mailboxID, draftID)
		if err != nil {
			return fmt.Errorf("failed to send forward (draft %s created but not sent): %w", draftID, err)
		}
		runtime.Out(map[string]interface{}{
			"message_id": resData["message_id"],
			"thread_id":  resData["thread_id"],
		}, nil)
		hintMarkAsRead(runtime, mailboxID, messageId)
		return nil
	},
}
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)
		return nil
	},
}
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)
		return nil
	},
}
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: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 to reply to", Required: true},
		{Name: "body", Desc: "Required. Reply body. Prefer HTML for rich formatting; plain text is also supported. Body type is auto-detected from the reply body and the original message. Use --plain-text to force plain-text mode.", Required: true},
		{Name: "from", Desc: "Sender address; also selects the mailbox to send from (defaults to the authenticated user's primary mailbox)"},
		{Name: "to", Desc: "Additional To address(es), comma-separated (appended to original sender's address)"},
		{Name: "cc", Desc: "Additional CC email address(es), comma-separated"},
		{Name: "bcc", Desc: "BCC email address(es), comma-separated"},
		{Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring all HTML auto-detection. Cannot be used with --inline."},
		{Name: "attach", Desc: "Attachment file path(s), comma-separated"},
		{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<local-path>\"}. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
		{Name: "confirm-send", Type: "bool", Desc: "Send the reply immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		messageId := runtime.Str("message-id")
		confirmSend := runtime.Bool("confirm-send")
		mailboxID := resolveComposeMailboxID(runtime)
		desc := "Reply: fetch original message → fetch mailbox profile (default From) → save as draft"
		if confirmSend {
			desc = "Reply (--confirm-send): fetch original message → fetch mailbox profile (default From) → create draft → send draft"
		}
		api := common.NewDryRunAPI().
			Desc(desc).
			GET(mailboxPath(mailboxID, "messages", messageId)).
			GET(mailboxPath(mailboxID, "profile")).
			POST(mailboxPath(mailboxID, "drafts")).
			Body(map[string]interface{}{"raw": "<base64url-EML>"})
		if confirmSend {
			api = api.POST(mailboxPath(mailboxID, "drafts", "<draft_id>", "send"))
		}
		return api
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		return validateComposeInlineAndAttachments(runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		messageId := runtime.Str("message-id")
		body := runtime.Str("body")
		fromFlag := runtime.Str("from")
		toFlag := runtime.Str("to")
		ccFlag := runtime.Str("cc")
		bccFlag := runtime.Str("bcc")
		plainText := runtime.Bool("plain-text")
		attachFlag := runtime.Str("attach")
		inlineFlag := runtime.Str("inline")
		confirmSend := runtime.Bool("confirm-send")

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

		mailboxID := resolveComposeMailboxID(runtime)
		sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
		if err != nil {
			return fmt.Errorf("failed to fetch original message: %w", err)
		}
		orig := sourceMsg.Original

		senderEmail := fromFlag
		if senderEmail == "" {
			senderEmail = fetchCurrentUserEmail(runtime)
			if senderEmail == "" {
				senderEmail = orig.headTo
			}
		}

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

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

		quoted := quoteForReply(&orig, useHTML)
		bld := emlbuilder.New().
			Subject(buildReplySubject(orig.subject)).
			ToAddrs(parseNetAddrs(replyTo))
		if senderEmail != "" {
			bld = bld.From("", senderEmail)
		}
		if ccFlag != "" {
			bld = bld.CCAddrs(parseNetAddrs(ccFlag))
		}
		if bccFlag != "" {
			bld = bld.BCCAddrs(parseNetAddrs(bccFlag))
		}
		if inReplyTo := normalizeMessageID(orig.smtpMessageId); inReplyTo != "" {
			bld = bld.InReplyTo(inReplyTo)
		}
		if messageId != "" {
			bld = bld.LMSReplyToMessageID(messageId)
		}
		if useHTML {
			if err := validateInlineImageURLs(sourceMsg); err != nil {
				return fmt.Errorf("HTML reply blocked: %w", err)
			}
			bld = bld.HTMLBody([]byte(bodyStr + quoted))
			bld, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages)
			if err != nil {
				return err
			}
		} else {
			bld = bld.TextBody([]byte(bodyStr + quoted))
		}
		for _, path := range splitByComma(attachFlag) {
			bld = bld.AddFileAttachment(path)
		}
		for _, spec := range inlineSpecs {
			bld = bld.AddFileInline(spec.FilePath, spec.CID)
		}
		rawEML, err := bld.BuildBase64URL()
		if err != nil {
			return fmt.Errorf("failed to build EML: %w", err)
		}

		draftID, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
		if err != nil {
			return fmt.Errorf("failed to create draft: %w", err)
		}
		if !confirmSend {
			runtime.Out(map[string]interface{}{
				"draft_id": draftID,
				"tip":      fmt.Sprintf(`draft saved. To send: lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"%s","draft_id":"%s"}'`, mailboxID, draftID),
			}, nil)
			hintSendDraft(runtime, mailboxID, draftID)
			return nil
		}
		resData, err := draftpkg.Send(runtime, mailboxID, draftID)
		if err != nil {
			return fmt.Errorf("failed to send reply (draft %s created but not sent): %w", draftID, err)
		}
		runtime.Out(map[string]interface{}{
			"message_id": resData["message_id"],
			"thread_id":  resData["thread_id"],
		}, nil)
		hintMarkAsRead(runtime, mailboxID, messageId)
		return nil
	},
}
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: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 to reply to all recipients", Required: true},
		{Name: "body", Desc: "Required. Reply body. Prefer HTML for rich formatting; plain text is also supported. Body type is auto-detected from the reply body and the original message. Use --plain-text to force plain-text mode.", Required: true},
		{Name: "from", Desc: "Sender address; also selects the mailbox to send from (defaults to the authenticated user's primary mailbox)"},
		{Name: "to", Desc: "Additional To address(es), comma-separated (appended to original recipients)"},
		{Name: "cc", Desc: "Additional CC email address(es), comma-separated"},
		{Name: "bcc", Desc: "BCC email address(es), comma-separated"},
		{Name: "remove", Desc: "Address(es) to exclude from the outgoing reply, comma-separated"},
		{Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring all HTML auto-detection. Cannot be used with --inline."},
		{Name: "attach", Desc: "Attachment file path(s), comma-separated"},
		{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<local-path>\"}. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
		{Name: "confirm-send", Type: "bool", Desc: "Send the reply immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		messageId := runtime.Str("message-id")
		confirmSend := runtime.Bool("confirm-send")
		mailboxID := resolveComposeMailboxID(runtime)
		desc := "Reply-all: fetch original message (with recipients) → fetch mailbox profile (default From) → save as draft"
		if confirmSend {
			desc = "Reply-all (--confirm-send): fetch original message (with recipients) → fetch mailbox profile (default From) → create draft → send draft"
		}
		api := common.NewDryRunAPI().
			Desc(desc).
			GET(mailboxPath(mailboxID, "messages", messageId)).
			GET(mailboxPath(mailboxID, "profile")).
			POST(mailboxPath(mailboxID, "drafts")).
			Body(map[string]interface{}{"raw": "<base64url-EML>"})
		if confirmSend {
			api = api.POST(mailboxPath(mailboxID, "drafts", "<draft_id>", "send"))
		}
		return api
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		return validateComposeInlineAndAttachments(runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		messageId := runtime.Str("message-id")
		body := runtime.Str("body")
		fromFlag := runtime.Str("from")
		toFlag := runtime.Str("to")
		ccFlag := runtime.Str("cc")
		bccFlag := runtime.Str("bcc")
		removeFlag := runtime.Str("remove")
		plainText := runtime.Bool("plain-text")
		attachFlag := runtime.Str("attach")
		inlineFlag := runtime.Str("inline")
		confirmSend := runtime.Bool("confirm-send")

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

		mailboxID := resolveComposeMailboxID(runtime)
		sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
		if err != nil {
			return fmt.Errorf("failed to fetch original message: %w", err)
		}
		orig := sourceMsg.Original

		senderEmail := fromFlag
		if senderEmail == "" {
			senderEmail = fetchCurrentUserEmail(runtime)
			if senderEmail == "" {
				senderEmail = orig.headTo
			}
		}

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

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

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

		useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw))
		if strings.TrimSpace(inlineFlag) != "" && !useHTML {
			return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
		}
		var bodyStr string
		if useHTML {
			bodyStr = buildBodyDiv(body, bodyIsHTML(body))
		} else {
			bodyStr = body
		}
		quoted := quoteForReply(&orig, useHTML)
		bld := emlbuilder.New().
			Subject(buildReplySubject(orig.subject)).
			ToAddrs(parseNetAddrs(toList))
		if senderEmail != "" {
			bld = bld.From("", senderEmail)
		}
		if ccList != "" {
			bld = bld.CCAddrs(parseNetAddrs(ccList))
		}
		if bccFlag != "" {
			bld = bld.BCCAddrs(parseNetAddrs(bccFlag))
		}
		if inReplyTo := normalizeMessageID(orig.smtpMessageId); inReplyTo != "" {
			bld = bld.InReplyTo(inReplyTo)
		}
		if messageId != "" {
			bld = bld.LMSReplyToMessageID(messageId)
		}
		if useHTML {
			if err := validateInlineImageURLs(sourceMsg); err != nil {
				return fmt.Errorf("HTML reply-all blocked: %w", err)
			}
			bld = bld.HTMLBody([]byte(bodyStr + quoted))
			bld, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages)
			if err != nil {
				return err
			}
		} else {
			bld = bld.TextBody([]byte(bodyStr + quoted))
		}
		for _, path := range splitByComma(attachFlag) {
			bld = bld.AddFileAttachment(path)
		}
		for _, spec := range inlineSpecs {
			bld = bld.AddFileInline(spec.FilePath, spec.CID)
		}
		rawEML, err := bld.BuildBase64URL()
		if err != nil {
			return fmt.Errorf("failed to build EML: %w", err)
		}

		draftID, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
		if err != nil {
			return fmt.Errorf("failed to create draft: %w", err)
		}
		if !confirmSend {
			runtime.Out(map[string]interface{}{
				"draft_id": draftID,
				"tip":      fmt.Sprintf(`draft saved. To send: lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"%s","draft_id":"%s"}'`, mailboxID, draftID),
			}, nil)
			hintSendDraft(runtime, mailboxID, draftID)
			return nil
		}
		resData, err := draftpkg.Send(runtime, mailboxID, draftID)
		if err != nil {
			return fmt.Errorf("failed to send reply-all (draft %s created but not sent): %w", draftID, err)
		}
		runtime.Out(map[string]interface{}{
			"message_id": resData["message_id"],
			"thread_id":  resData["thread_id"],
		}, nil)
		hintMarkAsRead(runtime, mailboxID, messageId)
		return nil
	},
}
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: "Required. Email subject", Required: true},
		{Name: "body", Desc: "Required. Email body. Prefer HTML for rich formatting (bold, lists, links); plain text is also supported. Body type is auto-detected. Use --plain-text to force plain-text mode.", Required: true},
		{Name: "from", Desc: "Sender address; also selects the mailbox to send from (defaults to the authenticated user's primary mailbox)"},
		{Name: "cc", Desc: "CC email address(es), comma-separated"},
		{Name: "bcc", Desc: "BCC email address(es), comma-separated"},
		{Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring HTML auto-detection. Cannot be used with --inline."},
		{Name: "attach", Desc: "Attachment file path(s), comma-separated"},
		{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<local-path>\"}. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
		{Name: "confirm-send", Type: "bool", Desc: "Send the email immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		to := runtime.Str("to")
		subject := runtime.Str("subject")
		confirmSend := runtime.Bool("confirm-send")
		mailboxID := resolveComposeMailboxID(runtime)
		desc := "Compose email → save as draft"
		if confirmSend {
			desc = "Compose email → save as draft → send draft"
		}
		api := common.NewDryRunAPI().
			Desc(desc).
			GET(mailboxPath(mailboxID, "profile")).
			POST(mailboxPath(mailboxID, "drafts")).
			Body(map[string]interface{}{
				"raw": "<base64url-EML>",
				"_preview": map[string]interface{}{
					"to":      to,
					"subject": subject,
				},
			})
		if confirmSend {
			api = api.POST(mailboxPath(mailboxID, "drafts", "<draft_id>", "send"))
		}
		return api
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		if err := validateComposeHasAtLeastOneRecipient(runtime.Str("to"), runtime.Str("cc"), runtime.Str("bcc")); err != nil {
			return err
		}
		return validateComposeInlineAndAttachments(runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body"))
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		to := runtime.Str("to")
		subject := runtime.Str("subject")
		body := runtime.Str("body")
		fromFlag := runtime.Str("from")
		ccFlag := runtime.Str("cc")
		bccFlag := runtime.Str("bcc")
		plainText := runtime.Bool("plain-text")
		attachFlag := runtime.Str("attach")
		inlineFlag := runtime.Str("inline")
		confirmSend := runtime.Bool("confirm-send")

		senderEmail := fromFlag
		if senderEmail == "" {
			senderEmail = fetchCurrentUserEmail(runtime)
		}

		bld := emlbuilder.New().
			Subject(subject).
			ToAddrs(parseNetAddrs(to))
		if senderEmail != "" {
			bld = bld.From("", senderEmail)
		}
		if ccFlag != "" {
			bld = bld.CCAddrs(parseNetAddrs(ccFlag))
		}
		if bccFlag != "" {
			bld = bld.BCCAddrs(parseNetAddrs(bccFlag))
		}
		if plainText {
			bld = bld.TextBody([]byte(body))
		} else if bodyIsHTML(body) {
			bld = bld.HTMLBody([]byte(body))
		} else {
			bld = bld.TextBody([]byte(body))
		}

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

		for _, path := range splitByComma(attachFlag) {
			bld = bld.AddFileAttachment(path)
		}

		for _, spec := range inlineSpecs {
			bld = bld.AddFileInline(spec.FilePath, spec.CID)
		}

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

		mailboxID := resolveComposeMailboxID(runtime)
		draftID, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
		if err != nil {
			return fmt.Errorf("failed to create draft: %w", err)
		}
		if !confirmSend {
			runtime.Out(map[string]interface{}{
				"draft_id": draftID,
				"tip":      fmt.Sprintf(`draft saved. To send: lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"%s","draft_id":"%s"}'`, mailboxID, draftID),
			}, nil)
			hintSendDraft(runtime, mailboxID, draftID)
			return nil
		}
		resData, err := draftpkg.Send(runtime, mailboxID, draftID)
		if err != nil {
			return fmt.Errorf("failed to send email (draft %s created but not sent): %w", draftID, err)
		}
		runtime.Out(map[string]interface{}{
			"message_id": resData["message_id"],
			"thread_id":  resData["thread_id"],
		}, nil)
		return nil
	},
}
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)
		return nil
	},
}
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 (both json/data output messages array only)"},
		{Name: "max", Type: "int", Default: "20", Desc: "maximum number of messages to fetch (1-400; auto-paginates internally)"},
		{Name: "filter", Desc: `exact-match condition filter (JSON). Narrow results by folder, label, sender, recipient, etc. Run --print-filter-schema to see all fields. Example: {"folder":"INBOX","from":["alice@example.com"]}`},
		{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
		{Name: "query", Desc: `full-text keyword search across from/to/subject/body (max 50 chars). Example: "budget report"`},
		{Name: "labels", Type: "bool", Desc: "include label IDs in output"},
		{Name: "print-filter-schema", Type: "bool", Desc: "print --filter field reference and exit"},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		mailbox := resolveMailboxID(runtime)
		query := runtime.Str("query")
		showLabels := runtime.Bool("labels")
		maxCount := normalizeTriageMax(runtime.Int("max"))
		filter, err := parseTriageFilter(runtime.Str("filter"))
		d := common.NewDryRunAPI().Set("input_filter", runtime.Str("filter"))
		if err != nil {
			return d.Set("filter_error", err.Error())
		}
		if usesTriageSearchPath(query, filter) {
			resolvedFilter, err := resolveSearchFilter(runtime, mailbox, filter, true)
			if err != nil {
				return d.Set("filter_error", err.Error())
			}
			pageSize := maxCount
			if pageSize > searchPageMax {
				pageSize = searchPageMax
			}
			searchParams, searchBody, _ := buildSearchParams(runtime, mailbox, query, resolvedFilter, pageSize, "", true)
			d = d.POST(mailboxPath(mailbox, "search")).
				Params(searchParams).
				Body(searchBody).
				Desc("search messages (auto-paginates up to --max)")
			if showLabels {
				d = d.POST(mailboxPath(mailbox, "messages", "batch_get")).
					Body(map[string]interface{}{"format": "metadata", "message_ids": []string{"<message_id>"}}).
					Desc("batch_get messages with format=metadata to populate labels")
			}
			return d
		}
		resolvedFilter, err := resolveListFilter(runtime, mailbox, filter, true)
		if err != nil {
			return d.Set("filter_error", err.Error())
		}
		pageSize := maxCount
		if pageSize > listPageMax {
			pageSize = listPageMax
		}
		listParams, _ := buildListParams(runtime, mailbox, resolvedFilter, pageSize, "", true)
		return d.GET(mailboxPath(mailbox, "messages")).
			Params(listParams).
			POST(mailboxPath(mailbox, "messages", "batch_get")).
			Body(map[string]interface{}{"format": "metadata", "message_ids": []string{"<message_id>"}}).
			Desc("list message IDs (auto-paginates up to --max); batch_get with format=metadata").
			Set("resolve_note", "name→ID resolution for filter.folder/filter.label runs during execution; dry-run does not call folders/labels list APIs")
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		if runtime.Bool("print-filter-schema") {
			printTriageFilterSchema(runtime)
			return nil
		}
		mailbox := resolveMailboxID(runtime)
		hintIdentityFirst(runtime, mailbox)
		outFormat := runtime.Str("format")
		query := runtime.Str("query")
		if query != "" {
			if err := common.RejectDangerousChars("--query", query); err != nil {
				return err
			}
		}
		showLabels := runtime.Bool("labels")
		filter, err := parseTriageFilter(runtime.Str("filter"))
		if err != nil {
			return err
		}
		maxCount := normalizeTriageMax(runtime.Int("max"))

		var messages []map[string]interface{}

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

		switch outFormat {
		case "json", "data":
			output.PrintJson(runtime.IO().Out, messages)
		default:
			if len(messages) == 0 {
				fmt.Fprintln(runtime.IO().ErrOut, "No messages found.")
				return nil
			}
			var rows []map[string]interface{}
			for _, msg := range messages {
				row := map[string]interface{}{
					"date":       sanitizeForTerminal(strVal(msg["date"])),
					"from":       sanitizeForTerminal(strVal(msg["from"])),
					"subject":    sanitizeForTerminal(strVal(msg["subject"])),
					"message_id": msg["message_id"],
				}
				if showLabels {
					row["labels"] = msg["labels"]
				}
				rows = append(rows, row)
			}
			output.PrintTable(runtime.IO().Out, rows)
			fmt.Fprintf(runtime.IO().ErrOut, "\n%d message(s)\n", len(messages))
			fmt.Fprintln(runtime.IO().ErrOut, "tip: use mail +message --message-id <id> to read full content")
		}
		return nil
	},
}
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.message:readonly", "mail:user_mailbox.folder:read", "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: "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 len(resolvedLabelIDs) > 0 {
			d.Set("filter_label_ids", strings.Join(resolvedLabelIDs, ","))
		}
		if len(resolvedFolderIDs) > 0 {
			d.Set("filter_folder_ids", strings.Join(resolvedFolderIDs, ","))
		}

		if msgFormat != "event" || len(resolvedLabelIDs) > 0 || len(resolvedFolderIDs) > 0 {
			params := map[string]interface{}{
				"format": watchFetchFormat(msgFormat, len(resolvedLabelIDs) > 0 || len(resolvedFolderIDs) > 0),
			}
			d.GET(mailboxPath(mailbox, "messages", "{message_id}")).
				Params(params)
		}
		return d
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		if runtime.Bool("print-output-schema") {
			printWatchOutputSchema(runtime)
			return nil
		}
		mailbox := resolveMailboxID(runtime)
		hintIdentityFirst(runtime, mailbox)
		outFormat := runtime.Str("format")
		switch outFormat {
		case "json", "data", "":
		default:
			return output.ErrValidation("invalid --format %q: must be json or data", outFormat)
		}
		msgFormat := runtime.Str("msg-format")
		outputDir := runtime.Str("output-dir")
		if outputDir != "" {
			if outputDir == "~" || strings.HasPrefix(outputDir, "~/") {
				home, err := os.UserHomeDir()
				if err != nil {
					return fmt.Errorf("cannot expand ~: %w", err)
				}
				if outputDir == "~" {
					outputDir = home
				} else {
					outputDir = filepath.Join(home, outputDir[2:])
				}
			} else if filepath.IsAbs(outputDir) {
				outputDir = filepath.Clean(outputDir)
			} else {
				safePath, err := validate.SafeOutputPath(outputDir)
				if err != nil {
					return err
				}
				outputDir = safePath
			}

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

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

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

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

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

		mailboxFilter := ""
		if mailbox != "me" {
			mailboxFilter = mailbox
		}

		eventCount := 0

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

			eventBody := extractMailEventBody(data)

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

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

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

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

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

			eventCount++

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

			fullMessage := message

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

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

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

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

		sdkLogger := &mailWatchLogger{w: errOut}

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

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

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

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

		sigCh := make(chan os.Signal, 1)
		signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
		go func() {
			defer func() {
				if r := recover(); r != nil {
					fmt.Fprintf(errOut, "panic in signal handler: %v\n", r)
				}
			}()
			<-sigCh
			info(fmt.Sprintf("\nShutting down... (received %d events)", eventCount))
			signal.Stop(sigCh)
			os.Exit(0)
		}()

		info("Connected. Waiting for mail events... (Ctrl+C to stop)")
		if err := cli.Start(ctx); err != 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