Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 61 additions & 8 deletions shortcuts/mail/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,24 +218,24 @@ func mailboxPath(mailboxID string, segments ...string) string {
}

// fetchMailboxPrimaryEmail retrieves mailbox primary_email_address from
// user_mailboxes.profile. Returns empty string on failure (non-fatal).
func fetchMailboxPrimaryEmail(runtime *common.RuntimeContext, mailboxID string) string {
// user_mailboxes.profile. Returns the email address or an error.
func fetchMailboxPrimaryEmail(runtime *common.RuntimeContext, mailboxID string) (string, error) {
if mailboxID == "" {
mailboxID = "me"
}
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "profile"), nil, nil)
if err != nil {
return ""
return "", err
}
if email := extractPrimaryEmail(data); email != "" {
return email
return email, nil
}
if nested, ok := data["data"].(map[string]interface{}); ok {
if email := extractPrimaryEmail(nested); email != "" {
return email
return email, nil
}
}
return ""
return "", fmt.Errorf("profile API returned no primary_email_address")
}

func extractPrimaryEmail(data map[string]interface{}) string {
Expand All @@ -252,7 +252,8 @@ func extractPrimaryEmail(data map[string]interface{}) string {

// fetchCurrentUserEmail retrieves the current mailbox primary email.
func fetchCurrentUserEmail(runtime *common.RuntimeContext) string {
return fetchMailboxPrimaryEmail(runtime, "me")
email, _ := fetchMailboxPrimaryEmail(runtime, "me")
return email
}

// fetchSelfEmailSet returns a set containing the primary email of the given
Expand All @@ -264,7 +265,7 @@ func fetchSelfEmailSet(runtime *common.RuntimeContext, mailboxID string) map[str
mailboxID = "me"
}
set := make(map[string]bool)
if email := fetchMailboxPrimaryEmail(runtime, mailboxID); email != "" {
if email, _ := fetchMailboxPrimaryEmail(runtime, mailboxID); email != "" {
set[strings.ToLower(email)] = true
}
return set
Expand Down Expand Up @@ -680,6 +681,9 @@ func addUniqueID(dst *[]string, seen map[string]bool, id string) {
}

func listMailboxFolders(runtime *common.RuntimeContext, mailboxID string) ([]folderInfo, error) {
if err := validateFolderReadScope(runtime); err != nil {
return nil, err
}
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "folders"), nil, nil)
if err != nil {
return nil, output.ErrValidation("unable to resolve --folder: failed to list folders (%v). %s", err, resolveLookupHint("folder", mailboxID))
Expand All @@ -701,6 +705,9 @@ func listMailboxFolders(runtime *common.RuntimeContext, mailboxID string) ([]fol
}

func listMailboxLabels(runtime *common.RuntimeContext, mailboxID string) ([]labelInfo, error) {
if err := validateLabelReadScope(runtime); err != nil {
return nil, err
}
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "labels"), nil, nil)
if err != nil {
return nil, output.ErrValidation("unable to resolve --label: failed to list labels (%v). %s", err, resolveLookupHint("label", mailboxID))
Expand Down Expand Up @@ -1882,6 +1889,52 @@ func validateConfirmSendScope(runtime *common.RuntimeContext) error {
return nil
}

// validateFolderReadScope checks that the user's token includes the
// mail:user_mailbox.folder:read scope. Called on-demand by listMailboxFolders
// before hitting the folders API. System folders are resolved locally and
// never reach this check.
func validateFolderReadScope(runtime *common.RuntimeContext) error {
appID := runtime.Config.AppID
userOpenId := runtime.UserOpenId()
if appID == "" || userOpenId == "" {
return nil
}
stored := auth.GetStoredToken(appID, userOpenId)
if stored == nil {
return nil
}
required := []string{"mail:user_mailbox.folder:read"}
if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 {
return output.ErrWithHint(output.ExitAuth, "missing_scope",
fmt.Sprintf("folder resolution requires scope: %s", strings.Join(missing, ", ")),
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to grant folder read permission", strings.Join(missing, " ")))
}
return nil
}

// validateLabelReadScope checks that the user's token includes the
// mail:user_mailbox.message:modify scope. Called on-demand by listMailboxLabels
// before hitting the labels API. System labels are resolved locally and
// never reach this check.
func validateLabelReadScope(runtime *common.RuntimeContext) error {
appID := runtime.Config.AppID
userOpenId := runtime.UserOpenId()
if appID == "" || userOpenId == "" {
return nil
}
stored := auth.GetStoredToken(appID, userOpenId)
if stored == nil {
return nil
}
required := []string{"mail:user_mailbox.message:modify"}
if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 {
return output.ErrWithHint(output.ExitAuth, "missing_scope",
fmt.Sprintf("label resolution requires scope: %s", strings.Join(missing, ", ")),
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to grant label access permission", strings.Join(missing, " ")))
}
return nil
}

func validateComposeHasAtLeastOneRecipient(to, cc, bcc string) error {
if strings.TrimSpace(to) == "" && strings.TrimSpace(cc) == "" && strings.TrimSpace(bcc) == "" {
return fmt.Errorf("at least one recipient (--to, --cc, or --bcc) is required")
Expand Down
64 changes: 55 additions & 9 deletions shortcuts/mail/mail_watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
Expand All @@ -16,6 +17,7 @@ import (
"regexp"
"sort"
"strings"
"sync"
"syscall"

larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
Expand Down Expand Up @@ -79,8 +81,8 @@ var MailWatch = common.Shortcut{
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"},
Scopes: []string{"mail:event", "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)"},
Expand Down Expand Up @@ -138,6 +140,11 @@ var MailWatch = common.Shortcut{
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, ","))
}
Expand Down Expand Up @@ -244,11 +251,24 @@ var MailWatch = common.Shortcut{
}
info("Mailbox subscribed.")

// mailboxFilter: only apply event-level filtering when an explicit email address is given
// "me" is a server-side alias and cannot be matched against event.mail_address
mailboxFilter := ""
if mailbox != "me" {
mailboxFilter = mailbox
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
}
Comment thread
haidaodashushu marked this conversation as resolved.

// 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 {
unsubscribe() //nolint:errcheck // best-effort cleanup; primary error is profileErr
return enhanceProfileError(profileErr)
}
mailboxFilter = resolved
}
Comment thread
haidaodashushu marked this conversation as resolved.

eventCount := 0
Expand All @@ -257,10 +277,10 @@ var MailWatch = common.Shortcut{
// Extract event body
eventBody := extractMailEventBody(data)

// Filter by --mailbox (only when an explicit email address was provided)
// Filter by --mailbox
if mailboxFilter != "" {
mailAddr, _ := eventBody["mail_address"].(string)
if mailAddr != mailboxFilter {
if !strings.EqualFold(mailAddr, mailboxFilter) {
return
}
}
Expand Down Expand Up @@ -414,12 +434,19 @@ var MailWatch = common.Shortcut{
}()
<-sigCh
info(fmt.Sprintf("\nShutting down... (received %d events)", eventCount))
info("Unsubscribing mailbox events...")
if unsubErr := unsubscribe(); unsubErr != nil {
fmt.Fprintf(errOut, "Warning: unsubscribe failed: %v\n", unsubErr)
} else {
info("Mailbox unsubscribed.")
}
signal.Stop(sigCh)
os.Exit(0)
}()

info("Connected. Waiting for mail events... (Ctrl+C to stop)")
if err := cli.Start(ctx); err != nil {
unsubscribe() //nolint:errcheck // best-effort cleanup
return output.ErrNetwork("WebSocket connection failed: %v", err)
}
return nil
Expand Down Expand Up @@ -692,6 +719,25 @@ func wrapWatchSubscribeError(err error) error {
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
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

// 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.
Expand Down
55 changes: 29 additions & 26 deletions shortcuts/mail/mail_watch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,19 +87,22 @@ func TestMailWatchDryRunDefaultMetadataFetchesMessage(t *testing.T) {
runtime := runtimeForMailWatchTest(t, map[string]string{})

apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime))
if len(apis) != 2 {
t.Fatalf("expected 2 dry-run apis, got %d", len(apis))
if len(apis) != 3 {
t.Fatalf("expected 3 dry-run apis, got %d", len(apis))
}
if apis[0].Method != "POST" {
t.Fatalf("unexpected method: %s", apis[0].Method)
}
if apis[0].URL != mailboxPath("me", "event", "subscribe") {
t.Fatalf("unexpected url: %s", apis[0].URL)
}
if apis[1].URL != mailboxPath("me", "messages", "{message_id}") {
t.Fatalf("unexpected fetch url: %s", apis[1].URL)
if apis[1].Method != "GET" || apis[1].URL != mailboxPath("me", "profile") {
t.Fatalf("unexpected profile api: %s %s", apis[1].Method, apis[1].URL)
}
if got := apis[1].Params["format"]; got != "metadata" {
if apis[2].URL != mailboxPath("me", "messages", "{message_id}") {
t.Fatalf("unexpected fetch url: %s", apis[2].URL)
}
if got := apis[2].Params["format"]; got != "metadata" {
t.Fatalf("unexpected fetch format: %#v", got)
}
}
Expand All @@ -110,16 +113,16 @@ func TestMailWatchDryRunMetadataFormatFetchesMessage(t *testing.T) {
})

apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime))
if len(apis) != 2 {
t.Fatalf("expected 2 dry-run apis, got %d", len(apis))
if len(apis) != 3 {
t.Fatalf("expected 3 dry-run apis, got %d", len(apis))
}
if apis[1].Method != "GET" {
t.Fatalf("unexpected fetch method: %s", apis[1].Method)
if apis[2].Method != "GET" {
t.Fatalf("unexpected fetch method: %s", apis[2].Method)
}
if apis[1].URL != mailboxPath("me", "messages", "{message_id}") {
t.Fatalf("unexpected fetch url: %s", apis[1].URL)
if apis[2].URL != mailboxPath("me", "messages", "{message_id}") {
t.Fatalf("unexpected fetch url: %s", apis[2].URL)
}
if got := apis[1].Params["format"]; got != "metadata" {
if got := apis[2].Params["format"]; got != "metadata" {
t.Fatalf("unexpected fetch format: %#v", got)
}
}
Expand All @@ -130,10 +133,10 @@ func TestMailWatchDryRunMinimalFormatFetchesMessage(t *testing.T) {
})

apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime))
if len(apis) != 2 {
t.Fatalf("expected 2 dry-run apis, got %d", len(apis))
if len(apis) != 3 {
t.Fatalf("expected 3 dry-run apis, got %d", len(apis))
}
if got := apis[1].Params["format"]; got != "metadata" {
if got := apis[2].Params["format"]; got != "metadata" {
t.Fatalf("unexpected fetch format: %#v", got)
}
}
Expand Down Expand Up @@ -173,10 +176,10 @@ func TestMailWatchDryRunPlainTextFullFormatFetchesMessage(t *testing.T) {
})

apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime))
if len(apis) != 2 {
t.Fatalf("expected 2 dry-run apis, got %d", len(apis))
if len(apis) != 3 {
t.Fatalf("expected 3 dry-run apis, got %d", len(apis))
}
if got := apis[1].Params["format"]; got != "plain_text_full" {
if got := apis[2].Params["format"]; got != "plain_text_full" {
t.Fatalf("unexpected fetch format: %#v", got)
}
}
Expand All @@ -187,10 +190,10 @@ func TestMailWatchDryRunFullFormatUsesFull(t *testing.T) {
})

apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime))
if len(apis) != 2 {
t.Fatalf("expected 2 dry-run apis, got %d", len(apis))
if len(apis) != 3 {
t.Fatalf("expected 3 dry-run apis, got %d", len(apis))
}
if got := apis[1].Params["format"]; got != "full" {
if got := apis[2].Params["format"]; got != "full" {
t.Fatalf("unexpected fetch format: %#v", got)
}
}
Expand All @@ -202,13 +205,13 @@ func TestMailWatchDryRunEventFormatWithLabelFilterFetchesMessage(t *testing.T) {
})

apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime))
if len(apis) != 2 {
t.Fatalf("expected 2 dry-run apis, got %d", len(apis))
if len(apis) != 3 {
t.Fatalf("expected 3 dry-run apis, got %d", len(apis))
}
if apis[1].URL != mailboxPath("me", "messages", "{message_id}") {
t.Fatalf("unexpected fetch url: %s", apis[1].URL)
if apis[2].URL != mailboxPath("me", "messages", "{message_id}") {
t.Fatalf("unexpected fetch url: %s", apis[2].URL)
}
if got := apis[1].Params["format"]; got != "metadata" {
if got := apis[2].Params["format"]; got != "metadata" {
t.Fatalf("unexpected fetch format: %#v", got)
}
}
Expand Down
2 changes: 1 addition & 1 deletion skills/lark-mail/references/lark-mail-watch.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

实时监听新邮件事件(`mail.user_mailbox.event.message_received_v1`)。

**权限要求:** 应用需要 `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`,且机器人需订阅事件 `mail.user_mailbox.event.message_received_v1`。
**权限要求:** 应用需要 `mail:event`、`mail:user_mailbox.message:readonly` 权限,以及字段权限 `mail:user_mailbox.message.address:read`、`mail:user_mailbox.message.subject:read`、`mail:user_mailbox.message.body:read`,且机器人需订阅事件 `mail.user_mailbox.event.message_received_v1`。按需权限(缺失时会提示申请):使用 `--folders` / `--folder-ids` 筛选自定义文件夹时需要 `mail:user_mailbox.folder:read`;使用 `--labels` / `--label-ids` 筛选自定义标签时需要 `mail:user_mailbox.message:modify`。

## 命令

Expand Down
Loading