mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 16:18:05 +08:00
* 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.
875 lines
28 KiB
Go
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
|
|
}
|