Files
larksuite-cli/shortcuts/mail/mail_watch.go
chenhuang 815db0c866 fix(mail): add missing scopes for mail +watch shortcut (#357)
* fix(mail): add missing event scope for mail watch

The mail +watch shortcut requires scope
mail:user_mailbox.event.mail_address:read to receive the mail_address
field in WebSocket event payloads, but this scope was neither declared
in the shortcut's Scopes list nor included in the auto-approve
(recommend.allow) set.

Without this scope, +watch events arrive without the mail_address field,
which breaks mailbox filtering and fetch-mailbox resolution.

- Add scope to mail +watch Scopes declaration
- Add scope to scope_overrides.json recommend.allow list so that
  auth login --recommend requests it automatically

* fix(mail): add missing mailbox profile scope for mail watch

The +watch shortcut calls fetchMailboxPrimaryEmail (GET
user_mailboxes/me/profile) to resolve the mailbox address for event
filtering, which requires scope mail:user_mailbox:readonly. All other
mail shortcuts that call this API (send, reply, forward, draft-create,
draft-edit) already declare this scope, but +watch did not.

* fix(mail): remove event scope from scope_overrides.json

The mail:user_mailbox.event.mail_address:read scope only needs to be
declared in the +watch shortcut's Scopes list, not in the global
recommend.allow set.
2026-04-13 17:22:28 +08:00

875 lines
28 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/signal"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"sync/atomic"
"syscall"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/shortcuts/common"
larkevent "github.com/larksuite/oapi-sdk-go/v3/event"
"github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
)
type mailWatchLogger struct {
w io.Writer
}
func (l *mailWatchLogger) Debug(_ context.Context, _ ...interface{}) {}
func (l *mailWatchLogger) Info(_ context.Context, args ...interface{}) {
fmt.Fprintln(l.w, append([]interface{}{"[SDK Info]"}, args...)...)
}
func (l *mailWatchLogger) Warn(_ context.Context, args ...interface{}) {
fmt.Fprintln(l.w, append([]interface{}{"[SDK Warn]"}, args...)...)
}
func (l *mailWatchLogger) Error(_ context.Context, args ...interface{}) {
fmt.Fprintln(l.w, append([]interface{}{"[SDK Error]"}, args...)...)
}
var _ larkcore.Logger = (*mailWatchLogger)(nil)
// handleMailWatchSignal processes a shutdown signal: logs status, unsubscribes
// mailbox events, restores default signal behavior for forced termination, and
// cancels the watch context.
func handleMailWatchSignal(errOut io.Writer, sig os.Signal, eventCount int64, unsubscribeWithLog func(), stopSignals func(), cancel context.CancelFunc) {
fmt.Fprintf(errOut, "\nShutting down (signal: %v)... (received %d events)\n", sig, eventCount)
// Restore default signal behavior so a second Ctrl+C can force terminate.
stopSignals()
signal.Reset(os.Interrupt, syscall.SIGTERM)
unsubscribeWithLog()
cancel()
}
const mailEventType = "mail.user_mailbox.event.message_received_v1"
// promptInjectionPatterns lists known prompt injection trigger phrases.
var promptInjectionPatterns = []string{
"ignore all previous",
"ignore previous instructions",
"disregard",
"you are now",
"system prompt",
"jailbreak",
"act as if",
"new instructions",
}
// detectPromptInjection reports whether content contains known prompt injection patterns.
// Content is normalized first to strip zero-width characters and other dangerous Unicode
// that could be used to bypass keyword matching (e.g. U+200B inserted inside a phrase).
func detectPromptInjection(content string) bool {
normalized := strings.ToLower(sanitizeForTerminal(content))
for _, p := range promptInjectionPatterns {
if strings.Contains(normalized, p) {
return true
}
}
return false
}
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, ","))
}
// When outputting message payload (or when label/folder filtering is enabled),
// +watch will fetch message details by message_id.
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 != "" {
// Reject all tilde-prefixed paths — SafeOutputPath treats "~/x" as a
// literal relative path (creating a directory named "~"), which is
// confusing. This also covers ~user/path forms.
if strings.HasPrefix(outputDir, "~") {
return output.ErrValidation("--output-dir does not support ~ expansion; use a relative path like ./output instead")
}
// Enforce CWD containment: reject absolute paths, path traversal,
// and symlink escapes. SafeOutputPath returns a resolved absolute path
// under CWD, preventing writes to arbitrary system directories.
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)
}
// Resolve --labels / --folders strictly as names, and --label-ids / --folder-ids strictly as IDs.
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
}
}
// Step 1: subscribe mailbox events (required before WebSocket pushes mail events)
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()
// Resolve "me" to the actual email address so we can filter events.
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{}) {
// Extract event body
eventBody := extractMailEventBody(data)
// Filter by --mailbox
if mailboxFilter != "" {
mailAddr, _ := eventBody["mail_address"].(string)
if !strings.EqualFold(mailAddr, mailboxFilter) {
return
}
}
messageID, _ := eventBody["message_id"].(string)
if messageID == "" {
return
}
// Use event's mail_address as the fetch mailbox when available,
// because "me" only resolves to the current user's mailbox but
// WebSocket events may arrive for other mailboxes the app can access.
fetchMailbox := mailbox
if eventAddr, _ := eventBody["mail_address"].(string); eventAddr != "" {
fetchMailbox = eventAddr
}
// Fetch message payload when needed:
// 1) msg-format != event (output message/meta)
// 2) label/folder filtering is enabled
// 3) output-dir is set (always fetch full for file writing)
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
}
}
// Filter by --labels/--folders + --label-ids/--folder-ids resolved ID sets (based on message metadata)
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)
// Prompt injection detection: warn when email body contains known injection patterns.
// Body fields may be base64url-encoded; decode before scanning.
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
}
}
}
// Save full message for file writing before any stdout trimming.
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
}
},
}
// extractMailEventBody extracts the event body from the Lark event envelope.
func extractMailEventBody(data map[string]interface{}) map[string]interface{} {
// V2 envelope: { header: {...}, event: { mail_address, message_id, ... } }
if event, ok := data["event"].(map[string]interface{}); ok {
return event
}
return data
}
func parseJSONArrayFlag(input, flagName string) ([]string, error) {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return nil, nil
}
var values []string
if err := json.Unmarshal([]byte(trimmed), &values); err != nil {
return nil, output.ErrValidation("invalid --%s: expected JSON array of strings, e.g. [\"INBOX\",\"SENT\"]", flagName)
}
out := make([]string, 0, len(values))
for _, value := range values {
v := strings.TrimSpace(value)
if v != "" {
out = append(out, v)
}
}
return out, nil
}
func parseJSONArrayFlagLoose(input string) []string {
values, err := parseJSONArrayFlag(input, "")
if err != nil {
return nil
}
return values
}
func mergeIDSet(set map[string]bool, ids []string) map[string]bool {
if len(ids) == 0 {
return set
}
if set == nil {
set = make(map[string]bool, len(ids))
}
for _, id := range ids {
if id == "" {
continue
}
set[id] = true
}
return set
}
func setKeys(set map[string]bool) []string {
if len(set) == 0 {
return nil
}
keys := make([]string, 0, len(set))
for k := range set {
if k != "" {
keys = append(keys, k)
}
}
sort.Strings(keys)
return keys
}
func resolveWatchFilterIDsForDryRun(explicitIDsInput, namesInput string, namesCanUseSystemIDs bool, systemResolver func(string) (string, bool)) ([]string, bool) {
explicitIDs := parseJSONArrayFlagLoose(explicitIDsInput)
names := parseJSONArrayFlagLoose(namesInput)
set := make(map[string]bool)
for _, raw := range explicitIDs {
if id, ok := systemResolver(raw); ok {
set[id] = true
continue
}
set[raw] = true
}
deferred := false
for _, raw := range names {
if namesCanUseSystemIDs {
if id, ok := systemResolver(raw); ok {
set[id] = true
continue
}
}
if strings.TrimSpace(raw) != "" {
deferred = true
}
}
return setKeys(set), deferred
}
func resolveWatchNames(
runtime *common.RuntimeContext,
mailboxID, input, flagName string,
resolveNames func(*common.RuntimeContext, string, []string) ([]string, error),
systemResolver func(string) (string, bool),
) ([]string, error) {
names, err := parseJSONArrayFlag(input, flagName)
if err != nil {
return nil, err
}
resolvedNames := make([]string, 0, len(names))
for _, raw := range names {
value := strings.TrimSpace(raw)
if value == "" {
continue
}
if id, ok := systemResolver(value); ok {
resolvedNames = append(resolvedNames, id)
continue
}
}
remainingNames := make([]string, 0, len(names))
for _, raw := range names {
value := strings.TrimSpace(raw)
if value == "" {
continue
}
if _, ok := systemResolver(value); ok {
continue
}
remainingNames = append(remainingNames, value)
}
rest, err := resolveNames(runtime, mailboxID, remainingNames)
if err != nil {
return nil, err
}
return append(resolvedNames, rest...), nil
}
func resolveWatchFilterIDs(
runtime *common.RuntimeContext,
mailboxID, explicitIDsInput, namesInput string,
resolveExplicitID func(*common.RuntimeContext, string, string) (string, error),
resolveNames func(*common.RuntimeContext, string, []string) ([]string, error),
systemResolver func(string) (string, bool),
explicitFlagName, namesFlagName, kind string,
) ([]string, error) {
explicitIDs, err := parseJSONArrayFlag(explicitIDsInput, explicitFlagName)
if err != nil {
return nil, err
}
resolvedNames, err := resolveWatchNames(runtime, mailboxID, namesInput, namesFlagName, resolveNames, systemResolver)
if err != nil {
return nil, err
}
set := make(map[string]bool)
for _, raw := range explicitIDs {
resolved := strings.TrimSpace(raw)
if id, ok := systemResolver(resolved); ok {
set[id] = true
continue
}
var err error
resolved, err = resolveExplicitID(runtime, mailboxID, resolved)
if err != nil {
return nil, err
}
if resolved != "" {
set[resolved] = true
}
}
return setKeys(mergeIDSet(set, resolvedNames)), nil
}
func watchFetchFormat(msgFormat string, forceMetadata bool) string {
if forceMetadata && msgFormat == "event" {
return "metadata"
}
switch msgFormat {
case "metadata", "plain_text_full", "full":
return msgFormat
case "minimal":
return "metadata"
default:
return "metadata"
}
}
func minimalWatchMessage(message map[string]interface{}) map[string]interface{} {
if message == nil {
return nil
}
out := make(map[string]interface{}, 6)
for _, key := range []string{"message_id", "thread_id", "folder_id", "label_ids", "internal_date", "message_state"} {
if value, ok := message[key]; ok {
out[key] = value
}
}
return out
}
func watchFetchFailureValue(messageID, fetchFormat string, err error, eventBody map[string]interface{}) map[string]interface{} {
payload := map[string]interface{}{
"ok": false,
"error": map[string]interface{}{
"type": "fetch_message_failed",
"message_id": messageID,
"format": fetchFormat,
"message": err.Error(),
},
}
if len(eventBody) > 0 {
payload["event"] = eventBody
}
return payload
}
// fetchMessageForWatch fetches message payload used by watch output/filtering.
func fetchMessageForWatch(runtime *common.RuntimeContext, mailbox, messageID, format string) (map[string]interface{}, error) {
queryParams := make(larkcore.QueryParams)
queryParams.Set("format", format)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/mail/v1/user_mailboxes/%s/messages/%s", validate.EncodePathSegment(mailbox), validate.EncodePathSegment(messageID)),
QueryParams: queryParams,
})
if err != nil {
return nil, err
}
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return nil, err
}
if code, _ := result["code"].(float64); code != 0 {
msg, _ := result["msg"].(string)
return nil, fmt.Errorf("[%.0f] %s", code, msg)
}
data, _ := result["data"].(map[string]interface{})
msg, _ := data["message"].(map[string]interface{})
if msg == nil {
return data, nil
}
return msg, nil
}
// messageHasLabel checks if a message metadata map contains any of the given label IDs.
func messageHasLabel(meta map[string]interface{}, labelIDSet map[string]bool) bool {
labels, _ := meta["label_ids"].([]interface{})
for _, l := range labels {
if id, ok := l.(string); ok && labelIDSet[id] {
return true
}
}
return false
}
func wrapWatchSubscribeError(err error) error {
if err == nil {
return nil
}
hint := "ensure the app has scope mail:event and the event mail.user_mailbox.event.message_received_v1 is enabled"
if exitErr, ok := err.(*output.ExitError); ok && exitErr.Detail != nil {
msg := "subscribe mailbox events failed: " + exitErr.Detail.Message
if exitErr.Detail.Hint != "" {
hint = exitErr.Detail.Hint + "; " + hint
}
return output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, msg, hint)
}
return output.ErrWithHint(output.ExitAPI, "api_error", fmt.Sprintf("subscribe mailbox events failed: %v", err), hint)
}
// enhanceProfileError wraps a profile API error with actionable hints.
// Permission errors get a scope-specific hint; other errors (network, 5xx)
// are reported as-is so diagnostics aren't misleading.
func enhanceProfileError(err error) error {
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
errType := exitErr.Detail.Type
lower := strings.ToLower(exitErr.Detail.Message)
if errType == "permission" || errType == "missing_scope" ||
strings.Contains(lower, "permission") || strings.Contains(lower, "scope") {
return output.ErrWithHint(output.ExitAuth, "missing_scope",
"unable to resolve mailbox address: "+exitErr.Detail.Message,
"run `lark-cli auth login --scope \"mail:user_mailbox:readonly\"` to grant mailbox profile access")
}
}
// Preserve original error (and its exit code) for non-permission failures.
return err
}
// decodeBodyFieldsForFile returns a shallow copy of outputData with body_html and
// body_plain_text decoded from base64url, so that files saved via --output-dir contain
// human-readable content instead of raw base64 strings.
// It handles both a top-level message map and a {"message": {...}} wrapper.
func decodeBodyFieldsForFile(data interface{}) interface{} {
m, ok := data.(map[string]interface{})
if !ok {
return data
}
out := make(map[string]interface{}, len(m))
for k, v := range m {
out[k] = v
}
decodeBodyFields(out, out)
if msg, ok := out["message"].(map[string]interface{}); ok {
decoded := make(map[string]interface{}, len(msg))
for k, v := range msg {
decoded[k] = v
}
decodeBodyFields(decoded, decoded)
out["message"] = decoded
}
return out
}
// writeMailEventFile writes a mail event to a JSON file in outputDir.
// outputDir must be an already-resolved absolute path (symlinks resolved by Execute).
func writeMailEventFile(outputDir string, data interface{}, raw map[string]interface{}) (string, error) {
sanitizeFilename := func(s string) string {
safe := regexp.MustCompile(`[^a-zA-Z0-9._\-]+`).ReplaceAllString(s, "_")
return strings.Trim(safe, "_")
}
createTime := ""
if header, ok := raw["header"].(map[string]interface{}); ok {
createTime, _ = header["create_time"].(string)
}
if createTime == "" {
createTime = fmt.Sprintf("%d", os.Getpid())
}
// Sanitize createTime to prevent path traversal via e.g. "2026/03/24" → subdirectory creation.
createTime = sanitizeFilename(createTime)
if createTime == "" {
createTime = "unknown"
}
// Extract sender name and subject from message payload; fall back to event_id.
subject := ""
senderName := ""
if msg, ok := data.(map[string]interface{}); ok {
subject, _ = msg["subject"].(string)
senderName, _ = msg["from"].(string)
}
var stem string
if subject != "" || senderName != "" {
parts := []string{}
if senderName != "" {
parts = append(parts, senderName)
}
if subject != "" {
parts = append(parts, subject)
}
raw := strings.Join(parts, "_")
safe := sanitizeFilename(raw)
if len(safe) > 80 {
safe = safe[:80]
}
stem = safe
} else {
eventID := "unknown"
if header, ok := raw["header"].(map[string]interface{}); ok {
if id, _ := header["event_id"].(string); id != "" {
eventID = sanitizeFilename(id)
}
}
if eventID == "" {
eventID = "unknown"
}
stem = eventID
}
filename := fmt.Sprintf("%s_%s.json", stem, createTime)
fp := filepath.Join(outputDir, filename)
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
return "", err
}
if err := validate.AtomicWrite(fp, append(jsonData, '\n'), 0600); err != nil {
return "", err
}
return fp, nil
}