Skip to content

fix(mail): on-demand scope checks and watch event filtering#198

Merged
haidaodashushu merged 2 commits intomainfrom
fix/mail-watch-scope-on-demand
Apr 2, 2026
Merged

fix(mail): on-demand scope checks and watch event filtering#198
haidaodashushu merged 2 commits intomainfrom
fix/mail-watch-scope-on-demand

Conversation

@haidaodashushu
Copy link
Copy Markdown
Collaborator

@haidaodashushu haidaodashushu commented Apr 1, 2026

Summary

  • Remove mail:user_mailbox.folder:read from watch's static Scopes; add validateFolderReadScope and validateLabelReadScope that check permissions on-demand only when listMailboxFolders / listMailboxLabels is actually called (same pattern as validateConfirmSendScope).
  • Resolve --mailbox me to real email address via profile API for event filtering, preventing other users' mail events from being processed. Block startup if resolution fails, with error type distinction (permission vs transient).
  • Add unsubscribe cleanup (guarded by sync.Once) on all exit paths: SIGINT/SIGTERM, profile resolution failure, and WebSocket connection failure.
  • Remove bot from AuthTypes since bot tokens cannot subscribe to mailbox events.
  • Include profile lookup in dry-run output and update tests.
  • Update fetchMailboxPrimaryEmail to return error for proper diagnostics.

Test plan

  • mail +watch works without mail:user_mailbox.folder:read scope
  • mail +watch --folders '["inbox"]' works (system folder, no scope needed)
  • mail +watch --folders '["custom"]' prompts for folder:read scope when missing
  • mail +watch --labels '["custom"]' prompts for message:modify scope when missing
  • Events from other mailboxes are silently filtered out
  • Ctrl+C triggers unsubscribe before exit
  • Without mail:user_mailbox:readonly, startup fails with clear error
  • --dry-run shows profile lookup step

Summary by CodeRabbit

  • New Features

    • Case-insensitive mailbox address matching for event filtering
    • Better scope validation with clearer auth/missing-permission hints
    • Informational logging and improved unsubscription during shutdown
  • Bug Fixes

    • Fixed mailbox address resolution for --mailbox=me to correctly filter events
  • Tests

    • Updated dry-run tests to expect the added profile lookup call
  • Documentation

    • Clarified OAuth scope requirements for folder/label filtering and optional permissions

- Remove mail:user_mailbox.folder:read from watch's static Scopes; add
  validateFolderReadScope and validateLabelReadScope that check
  permissions on-demand when listMailboxFolders/listMailboxLabels is
  called (same pattern as validateConfirmSendScope).
- Resolve --mailbox me to real email address via profile API for event
  filtering, preventing other users' mail events from being processed.
  Block startup if resolution fails, with proper error type distinction.
- Add unsubscribe cleanup (guarded by sync.Once) on all exit paths:
  SIGINT/SIGTERM, profile resolution failure, and WebSocket failure.
- Remove bot from AuthTypes since bot tokens cannot subscribe.
- Include profile lookup in dry-run output and update tests.
- Update fetchMailboxPrimaryEmail to return error for diagnostics.
- Update documentation for on-demand scope requirements.
@github-actions github-actions bot added domain/mail PR touches the mail domain size/M Single-domain feat or fix with limited business impact labels Apr 1, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 1, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fa17fb50-6384-4f7c-bcd9-7638a6417bd9

📥 Commits

Reviewing files that changed from the base of the PR and between 7f3bcfb and c8152b3.

📒 Files selected for processing (1)
  • shortcuts/mail/mail_watch.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • shortcuts/mail/mail_watch.go

📝 Walkthrough

Walkthrough

Refactors mail shortcut error handling and scope validation, changes MailWatch to user-only auth with narrowed scopes, adds profile-based mailbox resolution and robust unsubscription flow, updates tests to expect an extra profile API call, and clarifies permission requirements in documentation.

Changes

Cohort / File(s) Summary
Helpers & Scope Validation
shortcuts/mail/helpers.go
fetchMailboxPrimaryEmail now returns (string, error). Added validateFolderReadScope and validateLabelReadScope to check stored OAuth scopes and return auth/hint errors when scopes are missing. Call sites adjusted to ignore the error where prior empty-string behavior is preserved.
MailWatch Behavior & Security
shortcuts/mail/mail_watch.go
Removed bot auth (user-only) and dropped folder read from required OAuth scopes. Added GET /me/profile in DryRun and runtime resolution for --mailbox=me using fetchMailboxPrimaryEmail. Reworked event filtering (case-insensitive match), added enhanceProfileError, sync.Once-guarded unsubscribe logic, and improved shutdown/unsubscribe logging.
Tests
shortcuts/mail/mail_watch_test.go
Adjusted DryRun tests to expect 3 API calls (profile request added); shifted subsequent message-fetch assertions to the new indices.
Documentation
skills/lark-mail/references/lark-mail-watch.md
Removed unconditional mail:user_mailbox.folder:read requirement; clarified conditional permissions required for --folders/--folder-ids (folder read) and --labels/--label-ids (message modify), keeping baseline event/message read scopes.

Sequence Diagram(s)

sequenceDiagram
participant Runtime
participant TokenStore
participant ProfileAPI as "Mail API\n(GET /me/profile)"
participant WS as "WebSocket\n(event stream)"
Runtime->>TokenStore: read stored scopes
Runtime->>ProfileAPI: GET /me/profile (dry-run or --mailbox=me)
ProfileAPI-->>Runtime: profile (primary_email) / error
Runtime->>WS: subscribe to mail events (mail:event)
WS-->>Runtime: message_received event (with from/to, labels, folder)
Runtime->>Runtime: resolve mailbox filter (case-insensitive compare)
alt matches filter
  Runtime->>Runtime: deliver event to shortcut
else not match
  Runtime-->>Runtime: ignore event
end
Runtime->>Runtime: on shutdown or WS failure, call unsubscribe (sync.Once)
Runtime->>WS: unsubscribe (best-effort)
WS-->>Runtime: unsubscribe ack / error
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I hopped through scopes and profile calls,
I fetched the email behind the walls.
I guard the unsubscribe with a single try,
Case‑folded matches beneath the sky.
✨📨🐇

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main changes: on-demand scope validation and event filtering improvements for the mail watch feature.
Description check ✅ Passed The description covers all required sections: Summary, Changes, Test Plan, and Related Issues, with detailed explanations of modifications and verification steps.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/mail-watch-scope-on-demand

Comment @coderabbitai help to get the list of available commands and usage tips.

@haidaodashushu haidaodashushu requested a review from infeng April 1, 2026 13:58
@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 1, 2026

🚀 PR Preview Install Guide

🧰 CLI update

npm i -g https://pkg.pr.new/larksuite/cli/@larksuite/cli@c8152b392c7cbe8acab76bfef3564d4094681672

🧩 Skill update

npx skills add larksuite/cli#fix/mail-watch-scope-on-demand -y -g

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Apr 1, 2026

Greptile Summary

This PR improves mail +watch in three main areas: (1) moves mail:user_mailbox.folder:read from a static required scope to an on-demand check inside listMailboxFolders/listMailboxLabels (matching the existing validateConfirmSendScope pattern), (2) resolves --mailbox me to the user's real email address via the profile API so that WebSocket events from other mailboxes are filtered out rather than processed, and (3) adds sync.Once-guarded unsubscribe cleanup on all exit paths (signal, profile failure, WebSocket error).

Key changes:

  • fetchMailboxPrimaryEmail now returns (string, error) for proper diagnostics instead of silently swallowing errors.
  • validateFolderReadScope / validateLabelReadScope perform token-stored scope checks on demand; they no-op silently when no stored token is found.
  • AuthTypes drops \"bot\" since bot tokens cannot subscribe to mailbox events.
  • Dry-run output gains a profile API step when --mailbox me.
  • Tests updated to expect 3 dry-run API calls instead of 2.

Two concerns worth addressing before merge:

  • Data race on unsubErr: the error value is written inside sync.Once.Do but read outside the Do body by whichever goroutine lost the race, creating a potential data race between the signal-handler goroutine and the main goroutine's error path.
  • Mandatory profile resolution blocks all --mailbox me users who lack mail:user_mailbox:readonly: the scope is no longer in the static Scopes list (so it's never prompted), yet profile resolution is now a hard startup requirement that causes an immediate failure after subscribing.

Confidence Score: 3/5

Not safe to merge as-is: mandatory profile resolution may silently break existing users who lack mail:user_mailbox:readonly, and the sync.Once error-return pattern has a data race.

Two P1 issues: (1) a data race on unsubErr between the signal-handler goroutine and the main goroutine's WebSocket-error path, and (2) profile resolution is now mandatory for the default --mailbox me path but mail:user_mailbox:readonly was removed from the static Scopes list — users who granted only the previously-required scopes will hit a hard failure at startup after subscribing, with no upfront prompt to acquire the new scope.

shortcuts/mail/mail_watch.go — unsubErr race and mandatory profile resolution; shortcuts/mail/helpers.go — silent error drop in fetchCurrentUserEmail

Important Files Changed

Filename Overview
shortcuts/mail/mail_watch.go Adds profile resolution for "me" mailbox (mandatory, blocks startup), sync.Once-guarded unsubscribe on all exit paths, and case-insensitive mailbox filter; introduces a data race on the shared unsubErr variable returned outside the Once body.
shortcuts/mail/helpers.go fetchMailboxPrimaryEmail signature upgraded to return error; on-demand validateFolderReadScope/validateLabelReadScope added; fetchCurrentUserEmail still silently drops errors.
shortcuts/mail/mail_watch_test.go Dry-run tests updated to expect 3 API calls (added profile GET at index 1 for mailbox="me"); mechanical and consistent with the new execution path.
skills/lark-mail/references/lark-mail-watch.md Documentation updated to reflect that folder/label scopes are conditional (on-demand), removing mail:user_mailbox.folder:read from the required list.

Sequence Diagram

sequenceDiagram
    participant User
    participant CLI as mail +watch
    participant API as Lark API
    participant WS as WebSocket

    User->>CLI: mail +watch --mailbox me
    CLI->>API: POST /mailboxes/me/event/subscribe
    API-->>CLI: 200 OK (subscribed)
    CLI->>API: GET /mailboxes/me/profile
    alt profile OK
        API-->>CLI: primary_email_address
        CLI->>WS: Connect & listen
        WS-->>CLI: mail event (mail_address=X)
        Note over CLI: EqualFold(X, resolved email)?
        alt match
            CLI->>API: GET /mailboxes/{email}/messages/{id}
            API-->>CLI: message payload
            CLI->>User: emit NDJSON
        else no match
            CLI->>CLI: discard event
        end
        User->>CLI: Ctrl+C (SIGINT)
        CLI->>API: POST /mailboxes/me/event/unsubscribe
        API-->>CLI: OK
        CLI->>User: exit 0
    else profile error (permission)
        API-->>CLI: 403
        CLI->>API: POST /mailboxes/me/event/unsubscribe (cleanup)
        CLI->>User: error + hint to grant mail:user_mailbox:readonly
    end
Loading

Reviews (2): Last reviewed commit: "fix(mail): preserve original error in en..." | Re-trigger Greptile

Return the original error directly for non-permission failures instead
of wrapping with fmt.Errorf, so structured exit codes (ExitNetwork,
ExitAPI) are preserved for scripting.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
shortcuts/mail/mail_watch.go (1)

248-250: ⚠️ Potential issue | 🟠 Major

Resolve me before the subscribe write.

The mailbox address is now a startup prerequisite, but event/subscribe is still called first. If /me/profile fails and the best-effort unsubscribe also fails, the command can leave a live subscription behind on a path that never started watching.

Also applies to: 263-269

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/mail/mail_watch.go` around lines 248 - 250, The current code calls
runtime.CallAPI("POST", mailboxPath(mailbox, "event", "subscribe"), ...) before
ensuring the mailbox identity (/me/profile) is resolved, which can leave a stray
subscription if /me/profile later fails; modify the flow in mail_watch.go so
that you call the /me/profile resolution (the routine that fetches/resolves the
mailbox address) and verify it succeeds before invoking mailboxPath(...) and
runtime.CallAPI for subscribe, and handle errors from the profile resolution
first (returning an error) so wrapWatchSubscribeError is only used after a
successful resolve; apply the same change for the second subscribe site
referenced (the block around lines 263-269) so both subscribe calls happen only
after /me/profile has completed successfully.
🧹 Nitpick comments (3)
shortcuts/mail/helpers.go (1)

253-256: Don't collapse the new profile lookup error back to "".

fetchCurrentUserEmail and fetchSelfEmailSet discard the fetchMailboxPrimaryEmail error, so callers in shortcuts/mail/mail_draft_create.go, Lines 124-129, and shortcuts/mail/mail_reply_all.go, Lines 86-102, still can't distinguish missing scope from transient profile failures.

Also applies to: 263-270

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/mail/helpers.go` around lines 253 - 256, fetchCurrentUserEmail (and
similarly fetchSelfEmailSet) currently swallows the error from
fetchMailboxPrimaryEmail and returns an empty string, preventing callers like
the functions in mail_draft_create.go and mail_reply_all.go from distinguishing
missing-scope vs transient failures; change fetchCurrentUserEmail and
fetchSelfEmailSet to propagate the error instead of discarding it (e.g., change
signatures to return (string, error) and return
fetchMailboxPrimaryEmail(runtime, "me") directly), and update the callers (the
uses in mail_draft_create.go and mail_reply_all.go) to handle the returned error
accordingly so they can react differently to scope-missing vs transient profile
errors.
shortcuts/mail/mail_watch.go (1)

84-85: Pure event mode still over-requires message read scopes.

Execute skips message fetches for --msg-format event runs without filters or --output-dir, but the shortcut still statically requires mail:user_mailbox.message:readonly and the field-read scopes. Consider moving those checks on-demand too so the least-privileged event-stream path stays usable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/mail/mail_watch.go` around lines 84 - 85, The Scopes declaration
currently always requires mailbox message read scopes even though Execute can
run in pure event mode without fetching messages; update the logic so the static
Scopes slice no longer includes mail:user_mailbox.message:readonly and the
field-read scopes by default, and instead perform on-demand scope checks inside
Execute (or helper called by Execute) right before any code that fetches or
accesses message fields (e.g., where message fetch branches or Write to
output-dir occur) so that runs with --msg-format event and no filters/output-dir
do not require message read scopes while other code paths still validate
required scopes.
shortcuts/mail/mail_watch_test.go (1)

86-217: Add coverage for the new startup failure branches.

These assertions only pin the dry-run request order. The new enhanceProfileError path and best-effort event/unsubscribe cleanup in MailWatch.Execute still look untested, which leaves the main regression surface of this PR without dedicated coverage.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/mail/mail_watch_test.go` around lines 86 - 217, Tests only assert
dry-run ordering; add unit tests that exercise the runtime startup failure
branches in MailWatch.Execute: simulate a profile fetch error to trigger
enhanceProfileError and assert the returned error wraps/contains that profile
error, and simulate a startup failure path that ensures the cleanup POST to
event/unsubscribe is called as a best-effort (even when the profile fetch or
subscription fails). Locate MailWatch.Execute and enhanceProfileError to wire in
test doubles (using runtimeForMailWatchTest or the existing
dryRunAPIsForMailWatchTest helpers) that return controlled failures and verify
the unsubscribe POST (mailboxPath("me","event","unsubscribe")) is attempted and
that errors are surfaced/mapped by enhanceProfileError accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@shortcuts/mail/mail_watch.go`:
- Around line 248-250: The current code calls runtime.CallAPI("POST",
mailboxPath(mailbox, "event", "subscribe"), ...) before ensuring the mailbox
identity (/me/profile) is resolved, which can leave a stray subscription if
/me/profile later fails; modify the flow in mail_watch.go so that you call the
/me/profile resolution (the routine that fetches/resolves the mailbox address)
and verify it succeeds before invoking mailboxPath(...) and runtime.CallAPI for
subscribe, and handle errors from the profile resolution first (returning an
error) so wrapWatchSubscribeError is only used after a successful resolve; apply
the same change for the second subscribe site referenced (the block around lines
263-269) so both subscribe calls happen only after /me/profile has completed
successfully.

---

Nitpick comments:
In `@shortcuts/mail/helpers.go`:
- Around line 253-256: fetchCurrentUserEmail (and similarly fetchSelfEmailSet)
currently swallows the error from fetchMailboxPrimaryEmail and returns an empty
string, preventing callers like the functions in mail_draft_create.go and
mail_reply_all.go from distinguishing missing-scope vs transient failures;
change fetchCurrentUserEmail and fetchSelfEmailSet to propagate the error
instead of discarding it (e.g., change signatures to return (string, error) and
return fetchMailboxPrimaryEmail(runtime, "me") directly), and update the callers
(the uses in mail_draft_create.go and mail_reply_all.go) to handle the returned
error accordingly so they can react differently to scope-missing vs transient
profile errors.

In `@shortcuts/mail/mail_watch_test.go`:
- Around line 86-217: Tests only assert dry-run ordering; add unit tests that
exercise the runtime startup failure branches in MailWatch.Execute: simulate a
profile fetch error to trigger enhanceProfileError and assert the returned error
wraps/contains that profile error, and simulate a startup failure path that
ensures the cleanup POST to event/unsubscribe is called as a best-effort (even
when the profile fetch or subscription fails). Locate MailWatch.Execute and
enhanceProfileError to wire in test doubles (using runtimeForMailWatchTest or
the existing dryRunAPIsForMailWatchTest helpers) that return controlled failures
and verify the unsubscribe POST (mailboxPath("me","event","unsubscribe")) is
attempted and that errors are surfaced/mapped by enhanceProfileError
accordingly.

In `@shortcuts/mail/mail_watch.go`:
- Around line 84-85: The Scopes declaration currently always requires mailbox
message read scopes even though Execute can run in pure event mode without
fetching messages; update the logic so the static Scopes slice no longer
includes mail:user_mailbox.message:readonly and the field-read scopes by
default, and instead perform on-demand scope checks inside Execute (or helper
called by Execute) right before any code that fetches or accesses message fields
(e.g., where message fetch branches or Write to output-dir occur) so that runs
with --msg-format event and no filters/output-dir do not require message read
scopes while other code paths still validate required scopes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0982ebe0-1172-4f59-a75c-11dc0396a124

📥 Commits

Reviewing files that changed from the base of the PR and between a703202 and 7f3bcfb.

📒 Files selected for processing (4)
  • shortcuts/mail/helpers.go
  • shortcuts/mail/mail_watch.go
  • shortcuts/mail/mail_watch_test.go
  • skills/lark-mail/references/lark-mail-watch.md

@CLAassistant
Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@haidaodashushu haidaodashushu merged commit f68a411 into main Apr 2, 2026
13 of 14 checks passed
@haidaodashushu haidaodashushu deleted the fix/mail-watch-scope-on-demand branch April 2, 2026 02:56
tuxedomm pushed a commit that referenced this pull request Apr 3, 2026
* fix(mail): on-demand scope checks, event filtering, and watch lifecycle

- Remove mail:user_mailbox.folder:read from watch's static Scopes; add
  validateFolderReadScope and validateLabelReadScope that check
  permissions on-demand when listMailboxFolders/listMailboxLabels is
  called (same pattern as validateConfirmSendScope).
- Resolve --mailbox me to real email address via profile API for event
  filtering, preventing other users' mail events from being processed.
  Block startup if resolution fails, with proper error type distinction.
- Add unsubscribe cleanup (guarded by sync.Once) on all exit paths:
  SIGINT/SIGTERM, profile resolution failure, and WebSocket failure.
- Remove bot from AuthTypes since bot tokens cannot subscribe.
- Include profile lookup in dry-run output and update tests.
- Update fetchMailboxPrimaryEmail to return error for diagnostics.
- Update documentation for on-demand scope requirements.

* fix(mail): preserve original error in enhanceProfileError fallback

Return the original error directly for non-permission failures instead
of wrapping with fmt.Errorf, so structured exit codes (ExitNetwork,
ExitAPI) are preserved for scripting.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

domain/mail PR touches the mail domain size/M Single-domain feat or fix with limited business impact

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants