Skip to content

feat(events): register mail message_received in unified pipeline#851

Open
dingding0418 wants to merge 1 commit into
larksuite:mainfrom
dingding0418:feat/event-mail-pipeline
Open

feat(events): register mail message_received in unified pipeline#851
dingding0418 wants to merge 1 commit into
larksuite:mainfrom
dingding0418:feat/event-mail-pipeline

Conversation

@dingding0418
Copy link
Copy Markdown

@dingding0418 dingding0418 commented May 12, 2026

Summary

Register mail.user_mailbox.event.message_received_v1 as a first-class EventKey under events/mail/, so mailbox new-message events can be consumed through the unified lark-cli event consume contract instead of the standalone mail +watch fork. PreConsume hook auto-opens the per-user server-side subscription and cleanup auto-unsubscribes on graceful shutdown — agents now use the same command shape for IM and mail.

Changes

  • Add events/mail/register.go registering mail.user_mailbox.event.message_received_v1 with Schema.Native (SDK type larkmail.P2UserMailboxEventMessageReceivedV1Data), mailbox param (default me), required scopes (mail:event, mail:user_mailbox.event.mail_address:read), AuthTypes: [user], and RequiredConsoleEvents so missing console event subscription fails loudly at startup
  • Wire PreConsume to call POST /open-apis/mail/v1/user_mailboxes/<mailbox>/event/subscribe and return a cleanup that calls unsubscribe (with a fresh context.WithTimeout so cleanup still reaches the server after the parent ctx is cancelled)
  • Wire mail keys into events/register.go
  • Add events/mail/register_test.go (10 tests): registration / native-key invariants, scope+auth metadata, mailbox param shape, default-mailbox path, custom mailbox in URL, slash escape (path-traversal defense), cleanup unsubscribe call, subscribe-failure must return nil cleanup, empty mailbox falls back to me, path helper assembly
  • Update skills/lark-event/SKILL.md: extend description to mention mail; add a Per-EventKey payload model section explaining the IM-vs-mail asymmetry; add a typical event consume → mail +message pipeline example
  • Update skills/lark-mail/SKILL.md and references/lark-mail-watch.md so the +watch row spells out when to use which path

Note on payload thickness (intentional in this P0)

The mail event is intentionally thin: event consume mail.user_mailbox.event.message_received_v1 emits message_id + mail_address + mailbox_type + subscriber only. Subject/body/attachments need a follow-up mail +message --message-id <id> call.

This is asymmetric with IM (whose WS payload natively carries content); the asymmetry is a Feishu Open Platform API design choice, not a framework limitation. Closing the asymmetry is scoped to follow-up PRs (see Next Steps).

Next Steps (follow-up PRs)

  • P1: add a Process function on the EventKey that fetches per-event message metadata via OAPI, so event consume emits subject/from/snippet inline (matching mail +watch's default --msg-format=metadata). Closes the IM-vs-mail payload asymmetry agents currently see.
  • P2: extract mail +watch's fetch + filter + format business logic into a shared internal/mail/eventfmt package; expose --msg-format / --folders / --labels as Params on the EventKey; mail +watch retires to a thin alias forwarding to the unified path with sensible defaults. After P2 the two paths are fully interchangeable.
  • P3: add a lint rule (events/lint_test.go) pinning larkws and event/dispatcher imports to events/, cmd/event/, shortcuts/event/ only — physical defense against future per-domain forks like the one this PR removes the conceptual basis for.

Test Plan

  • make unit-test passes (full suite green)
  • go vet ./... clean
  • golangci-lint v2.1.6 run ./events/... — 0 issues
  • lark-cli event list shows mail.user_mailbox.event.message_received_v1 under ── mail ──
  • lark-cli event schema mail.user_mailbox.event.message_received_v1 shows Pre-consume: yes, scopes, console events, mailbox param, and the SDK-derived output schema
  • End-to-end manual: lark-cli event consume mail.user_mailbox.event.message_received_v1 --as user --max-events 1 --timeout 10m — PreConsume opens subscription, NDJSON event streams to stdout when a real email arrives, cleanup unsubscribes on graceful exit (reason: limit)
  • mail +watch still works (verified to confirm back-compat)

Notes

  • The existing mail +watch shortcut is intentionally left in place — it offers richer payload modes (--msg-format minimal/metadata/plain_text_full/full) and folder/label filters that this minimal P0 unified path doesn't yet expose. Both paths share the same server-side subscription record; P1/P2 above explicitly target full feature parity so this duality is temporary.
  • Console event subscription mail.user_mailbox.event.message_received_v1 must be enabled (and the app published) in the developer console for events to flow — same prerequisite as mail +watch; the runtime RequiredConsoleEvents predicate now warns if it is missing.

@CLAassistant
Copy link
Copy Markdown

CLAassistant commented May 12, 2026

CLA assistant check
All committers have signed the CLA.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 12, 2026

📝 Walkthrough

Walkthrough

Adds a mail event key (mail.user_mailbox.event.message_received_v1) with an optional mailbox parameter and pre-consume subscribe/unsubscribe lifecycle, wires it into the global event registry, and updates skill docs and examples to document unified event consumption for mail.

Changes

Mail Event Support

Layer / File(s) Summary
Mail event key definition and pre-consume subscription
events/mail/register.go, events/mail/register_test.go
Defines mail.user_mailbox.event.message_received_v1 with an optional mailbox param (default "me"), user auth and required scopes; implements preConsumeMailSubscribe to POST-subscribe the user mailbox and return a cleanup that unsubscribes with a timeout; adds mailboxEventPath for URL-escaped paths. Tests validate registration metadata, mailbox param behavior, URL-escaping, subscribe/unsubscribe requests, and error cases.
Global event registry integration
events/register.go
Imports events/mail and appends mail.Keys() to the global event key registry during init(), enabling mail event keys to be registered alongside IM keys.
Skill documentation updates
skills/lark-event/SKILL.md, skills/lark-mail/SKILL.md, skills/lark-mail/references/lark-mail-watch.md
Extends lark-event docs with mailbox new-message stream examples (including --param mailbox=...), clarifies payload and ready-marker contracts, and documents the unified lark event consume entry equivalence with mail +watch plus server-side subscribe/unsubscribe semantics.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • larksuite/cli#198: Modifies mail event subscribe/unsubscribe flow and user-only auth for mailbox events.

Suggested labels

domain/mail, size/M

Suggested reviewers

  • haidaodashushu
  • chanthuang
  • infeng

Poem

🐰 A mailbox bells with every ping,
I hop to subscribe, then tightly cling.
On graceful exit I softly unbind,
Leaving no stray hooks behind. 📬✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Title accurately summarizes the main change—registering mail message_received in the unified event pipeline—and is concise and specific.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The pull request description thoroughly addresses all required template sections with comprehensive content covering motivation, detailed changes, test plan with verification checkboxes, and related issues.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actions github-actions Bot added domain/mail PR touches the mail domain size/L Large or sensitive change across domains or core paths labels May 12, 2026
Add events/mail/ following the IM pattern: the mail new-message-received
EventKey is now consumable via the unified event command, with PreConsume
opening the per-user mailbox subscription on startup and unsubscribing
on graceful shutdown.

Agents can now use a single contract for IM and mail:

  lark-cli event consume mail.user_mailbox.event.message_received_v1 --as user

The existing `mail +watch` shortcut is unchanged for back-compat; its
SKILL reference now points to the unified entry as preferred for new
flows that already use the lark event contract (--max-events / --timeout
/ stderr ready-marker).

Tests cover registration invariants, scope/auth metadata, mailbox param
defaulting, subscribe/unsubscribe lifecycle, URL-escape path-traversal
defense, and error propagation.

Note on payload thickness: the mail event is intentionally thin in this
P0 — stdout carries only message_id, mail_address, mailbox_type,
subscriber. Subject/body/attachments need a follow-up `mail +message`
call. SKILL docs (lark-event, lark-mail) explain the asymmetry vs IM
(whose WS payload natively carries content) and show the typical
fetch-on-event pipeline.

Next steps (follow-up PRs to reach full feature parity with mail +watch):

  - P1: add a Process function on the EventKey that fetches per-event
    message metadata via OAPI on each event, so `event consume` emits
    subject/from/snippet inline (matching mail +watch's default
    --msg-format=metadata behaviour). Closes the IM-vs-mail payload
    asymmetry agents currently see.
  - P2: extract mail +watch's fetch + filter + format business logic into
    a shared internal/mail/eventfmt package; expose --msg-format /
    --folders / --labels as Params on the EventKey; mail +watch retires
    to a thin alias that forwards to the unified path with sensible
    defaults. After P2, mail +watch and event consume are functionally
    interchangeable, fully unified.
  - P3: add a lint rule (events/lint_test.go) pinning larkws and
    event/dispatcher imports to events/, cmd/event/, shortcuts/event/
    only — physical defense against future per-domain forks like the
    one this PR removes the conceptual basis for.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@dingding0418 dingding0418 force-pushed the feat/event-mail-pipeline branch from db7ba0d to 2107be8 Compare May 12, 2026 11:56
@bubbmon233
Copy link
Copy Markdown
Collaborator

Thanks for the PR, the unified entry point is a nice direction! While reading through I had a few questions I wanted to raise — happy to discuss.

  1. Subscription dimension mismatch: Mail subscriptions are keyed by mailbox. event consume is keyed by event type. When two event consume processes run with different mailboxes, the second one never opens a subscription — it just hangs, receives no mail, and reports no error.

  2. Fewer features than mail +watch: event consume mail.xxx does not support --folders / --labels / --msg-format / --print-output-schema.

  3. Unsubscribe failure is silent: On exit, if the unsubscribe API call fails, the new code swallows the error with _, _ = .... The cleanup function is func() with no return value, so the caller cannot observe the error. The server-side subscription leaks, and the user is never told.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 12, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 65.88%. Comparing base (db1a3fc) to head (2107be8).
⚠️ Report is 6 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #851      +/-   ##
==========================================
+ Coverage   65.77%   65.88%   +0.10%     
==========================================
  Files         516      518       +2     
  Lines       48625    48798     +173     
==========================================
+ Hits        31985    32150     +165     
- Misses      13881    13885       +4     
- Partials     2759     2763       +4     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions
Copy link
Copy Markdown

🚀 PR Preview Install Guide

🧰 CLI update

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

🧩 Skill update

npx skills add dingding0418/cli#feat/event-mail-pipeline -y -g

@dingding0418
Copy link
Copy Markdown
Author

Thanks for the careful review — these are all real concerns. Let me set the framing first: this PR and the planned follow-ups are all driven by an AI-friendly
north star. The interface should give callers three things: a single entry point, no silent failures, and no leaking of internal implementation (concepts like the bus
daemon or FirstForKey shouldn't surface to the agent). The three issues you raised happen to land on each of those three principles. Replying to each:


1. Subscription dimension mismatch — real, framework-level

Root cause: internal/event/consume/consume.go keys FirstForKey on the EventKey alone, but mail's subscription identity is two-dimensional — (EventKey, mailbox). So with two event consume mail.xxx processes against the same bus daemon (one mailbox=A, one mailbox=B), the second one's PreConsume is skipped by
the framework as "already covered" → B's subscribe API is never called. On top of that, the bus dispatch doesn't filter by params → P2 receives P1's events too. The
result is silent hang + cross-pollination.

Wider observation: mail is the first EventKey in lark-cli where (EventKey, params) (rather than EventKey alone) determines the subscription identity. IM doesn't
have this property so the gap was never exposed. But this is a general pattern — drive comments (per file_token), task updates (per task_guid), and future
calendar / approval EventKeys will all hit the exact same bug. Fixing it once at the framework level benefits every per-resource subscription EventKey we'll ever add.

Proposed fix (Option A): lift the dedup key from one dimension to two.

  • Add SubscriptionKey bool to ParamDef: marks which params change subscription identity (mailbox=true, msg-format=false).
  • Add Match(params, raw) bool to KeyDefinition: the bus uses it to filter delivery, eliminating cross-pollination (mail's Match compares event.mail_address
    against the resolved params.mailbox).
  • Hub's keyCounts becomes map[(EventKey, paramsFingerprint)]int.
  • The Hello protocol gains a Params map[string]string field (wire-level change).
  • IM is unchanged (no SubscriptionKey params, no Match → fingerprint is always empty → degenerates back to today's one-dimensional behavior, fully
    backward-compatible).

FirstForKey isn't bypassed — it's just that with the lifted key, P1 (mailbox=A) and P2 (mailbox=B) are each "first" within their own sid, so subscribe API gets
called per target. The WebSocket and Feishu's subscription table don't move; this is purely about aligning the framework's internal bookkeeping dimensions with the
subscription table's.

Idempotency safety net: Feishu's event/subscribe API is idempotent. Even if the sid computation has a bug, the worst case degrades to "call subscribe once per
consumer" — still correct, just wasted API quota. Implementation risk is low.

2. Feature parity gap — same abstraction solves it alongside #1

event consume mail.xxx currently lacks --folders / --labels / --msg-format / --print-output-schema, leaving the agent experience fragmented.

Key observation: every missing flag maps cleanly to an event consume -p param=.... Once #1's framework upgrade lands, #2 falls out naturally:

mail +watch flag event consume equivalent SubscriptionKey Implemented in which hook
--mailbox -p mailbox=... true PreConsume: subscribe against the corresponding mailbox
--msg-format -p msg-format=metadata|full|... false Process: per-event call to messages API to enrich the output
--folders / --folder-ids -p folders=... false Match: fetch metadata via message_id, drop if folder doesn't match
--labels / --label-ids -p labels=... false Match (same pattern)
--print-output-schema event schema mail.xxx -p msg-format=... The schema command reflects the chosen format

Three hooks, three concerns:

  • PreConsume (handles SubscriptionKey-true params) → subscription lifecycle
  • Match (handles delivery-filter params) → consumers share the subscription, each filters locally
  • Process (handles output-format params) → per-event enrichment

After this, mail +watch becomes a thin alias forwarding to event consume mail.xxx -p msg-format=metadata -p ... (or a deprecation notice). The two paths become
functionally interchangeable, and from the agent's perspective the unification is real.

3. Silent unsubscribe failure — fixing now, Option A

Fully agreed, this is a real bug. The current cleanup is func() with no return value, and _, _ = rt.CallAPI(...) swallows the error. Worse, the framework prints
[event] cleanup done. regardless of actual outcome — agents reading that line will assume success when in fact a leak may have occurred.

Option A: change the framework's cleanup signature to return error.

// internal/event/types.go
PreConsume func(ctx, rt, params) (cleanup func() error, err error)

// internal/event/consume/consume.go
case lastForKey:
    fmt.Fprintf(errOut, "[event] running cleanup...\n")
    if err := cleanup(); err != nil {
        fmt.Fprintf(errOut,
            "[event] WARN: cleanup failed: %v "+
            "(server-side subscription may have leaked)\n", err)
    } else {
        fmt.Fprintf(errOut, "[event] cleanup done.\n")
    }

mail cleanup implementation:

cleanup := func() error {
    cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    if _, err := rt.CallAPI(cleanupCtx, "POST",
        mailboxEventPath(mailbox, "unsubscribe"), body); err != nil {
        return fmt.Errorf("unsubscribe mailbox=%s: %w", mailbox, err)
    }
    return nil
}

Wins: errors are explicit, the WARN line has a uniform format, the misleading "cleanup done" disappears on failure, and any future EventKey using PreConsume gets it
for free.

Will add a regression test: fakeClient returns a non-nil unsubErr, asserting cleanup() returns the wrapped error and the framework writes the WARN line.

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/L Large or sensitive change across domains or core paths

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants