diff --git a/events/mail/mail_message_received.go b/events/mail/mail_message_received.go new file mode 100644 index 000000000..e7ca942dd --- /dev/null +++ b/events/mail/mail_message_received.go @@ -0,0 +1,121 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "strings" + + "github.com/larksuite/cli/internal/event" +) + +const mailEventType = "mail.user_mailbox.event.message_received_v1" + +// MailMessageReceivedOutput is the flat shape; `desc` tags drive the reflected schema. +type MailMessageReceivedOutput struct { + Type string `json:"type" desc:"Event type; always mail.user_mailbox.event.message_received_v1"` + EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"` + Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string)" kind:"timestamp_ms"` + Mailbox string `json:"mailbox,omitempty" desc:"Mailbox address that received this message" kind:"email"` + MessageID string `json:"message_id,omitempty" desc:"Message ID (mail.open.access scoped)"` + Sender string `json:"sender,omitempty" desc:"Sender email address" kind:"email"` + Subject string `json:"subject,omitempty" desc:"Message subject"` + BodyExcerpt string `json:"body_excerpt,omitempty" desc:"Body excerpt (first ~140 chars, plain text)"` +} + +func processMailMessageReceived(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { + var envelope struct { + Header struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + CreateTime string `json:"create_time"` + } `json:"header"` + Event struct { + MailAddress string `json:"mail_address"` + MessageID string `json:"message_id"` + Sender string `json:"sender"` + Subject string `json:"subject"` + Body string `json:"body"` + } `json:"event"` + } + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload + } + body := envelope.Event.Body + if len(body) > 140 { + body = body[:140] + } + return json.Marshal(&MailMessageReceivedOutput{ + Type: envelope.Header.EventType, + EventID: envelope.Header.EventID, + Timestamp: envelope.Header.CreateTime, + Mailbox: envelope.Event.MailAddress, + MessageID: envelope.Event.MessageID, + Sender: envelope.Event.Sender, + Subject: envelope.Event.Subject, + BodyExcerpt: body, + }) +} + +// parseMailboxes reads comma-separated `mailbox` param, trims whitespace, drops empties, +// dedupes preserving first-seen order, and defaults to []string{"me"} when empty. +// Order matters: PreConsume subscribes sequentially and rolls back in reverse. +func parseMailboxes(raw string) []string { + seen := map[string]struct{}{} + out := []string{} + for _, mb := range strings.Split(raw, ",") { + mb = strings.TrimSpace(mb) + if mb == "" { + continue + } + if _, dup := seen[mb]; dup { + continue + } + seen[mb] = struct{}{} + out = append(out, mb) + } + if len(out) == 0 { + return []string{"me"} + } + return out +} + +// mailMessageReceivedPreConsume runs once per (appID, EventKey) on the FirstForKey +// consumer (consume.Run:86-95). It sequentially calls the mailbox business +// subscribe API for each parsed mailbox; on any failure it rolls back the +// already-subscribed mailboxes in reverse order (best-effort) and returns the +// wrapped error. On full success it returns a cleanup closure that consume.Run +// will invoke on lastForKey exit (or unconditionally on panic). +func mailMessageReceivedPreConsume(ctx context.Context, rt event.APIClient, params map[string]string) (func(), error) { + mailboxes := parseMailboxes(params["mailbox"]) + var subscribed []string + for _, mb := range mailboxes { + if _, err := rt.CallAPI(ctx, "POST", + "/open-apis/mail/v1/user_mailboxes/"+url.PathEscape(mb)+"/event/subscribe", + map[string]interface{}{"event_type": 1}); err != nil { + for i := len(subscribed) - 1; i >= 0; i-- { + _, _ = rt.CallAPI(ctx, "POST", + "/open-apis/mail/v1/user_mailboxes/"+url.PathEscape(subscribed[i])+"/event/unsubscribe", + map[string]interface{}{"event_type": 1}) + } + return nil, fmt.Errorf("mail event subscribe failed for %s: %w "+ + "(hint: ensure (1) you are logged in as user with required mail scopes, "+ + "(2) the app has subscribed to %s in the developer console, "+ + "(3) the user has access to the target mailbox)", + mb, err, mailEventType) + } + subscribed = append(subscribed, mb) + } + cleanup := func() { + for i := len(subscribed) - 1; i >= 0; i-- { + _, _ = rt.CallAPI(ctx, "POST", + "/open-apis/mail/v1/user_mailboxes/"+url.PathEscape(subscribed[i])+"/event/unsubscribe", + map[string]interface{}{"event_type": 1}) + } + } + return cleanup, nil +} diff --git a/events/mail/mail_message_received_test.go b/events/mail/mail_message_received_test.go new file mode 100644 index 000000000..b801bd43d --- /dev/null +++ b/events/mail/mail_message_received_test.go @@ -0,0 +1,451 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/internal/event" +) + +func TestMain(m *testing.M) { + for _, k := range Keys() { + event.RegisterKey(k) + } + os.Exit(m.Run()) +} + +// ---- KeyDefinition field assertions ---- + +func TestMailKeysReturnsMailMessageReceived(t *testing.T) { + keys := Keys() + if len(keys) != 1 { + t.Fatalf("expected 1 key, got %d", len(keys)) + } + if keys[0].Key != "mail.user_mailbox.event.message_received_v1" { + t.Errorf("unexpected key: %q", keys[0].Key) + } +} + +func TestMailKeyDefinitionScopesAlignsWithMailWatch(t *testing.T) { + // Single source of truth: mail_watch.go:98 (7 items, same order) + wantScopes := []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", + } + got := Keys()[0].Scopes + if len(got) != 7 { + t.Fatalf("expected 7 scopes, got %d: %v", len(got), got) + } + for i, want := range wantScopes { + if got[i] != want { + t.Errorf("scope[%d]: want %q, got %q", i, want, got[i]) + } + } +} + +func TestMailKeyDefinitionAuthTypesIsUserOnly(t *testing.T) { + at := Keys()[0].AuthTypes + if len(at) != 1 || at[0] != "user" { + t.Errorf("expected AuthTypes=[\"user\"], got %v", at) + } +} + +func TestMailKeyDefinitionRequiredConsoleEvents(t *testing.T) { + rce := Keys()[0].RequiredConsoleEvents + if len(rce) != 1 || rce[0] != "mail.user_mailbox.event.message_received_v1" { + t.Errorf("unexpected RequiredConsoleEvents: %v", rce) + } +} + +func TestMailKeyDefinitionParamMailboxDefault(t *testing.T) { + params := Keys()[0].Params + if len(params) != 1 { + t.Fatalf("expected 1 param, got %d", len(params)) + } + p := params[0] + if p.Name != "mailbox" { + t.Errorf("param name: want \"mailbox\", got %q", p.Name) + } + if p.Type != event.ParamString { + t.Errorf("param type: want ParamString, got %v", p.Type) + } + if p.Required { + t.Error("mailbox param must not be Required") + } + if p.Default != "me" { + t.Errorf("param default: want \"me\", got %q", p.Default) + } + if !strings.Contains(p.Description, "comma-separated") { + t.Errorf("param description should mention comma-separated, got %q", p.Description) + } +} + +// ---- parseMailboxes ---- + +func TestParseMailboxes_DefaultMe(t *testing.T) { + got := parseMailboxes("") + if len(got) != 1 || got[0] != "me" { + t.Errorf("expected [\"me\"], got %v", got) + } +} + +func TestParseMailboxes_TrimsWhitespace(t *testing.T) { + got := parseMailboxes(" alice@x , bob@x ") + want := []string{"alice@x", "bob@x"} + if len(got) != len(want) { + t.Fatalf("want %v, got %v", want, got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("[%d] want %q, got %q", i, want[i], got[i]) + } + } +} + +func TestParseMailboxes_DedupPreservingOrder(t *testing.T) { + got := parseMailboxes("alice@x,bob@x,alice@x,carol@x") + want := []string{"alice@x", "bob@x", "carol@x"} + if len(got) != len(want) { + t.Fatalf("want %v, got %v", want, got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("[%d] want %q, got %q", i, want[i], got[i]) + } + } +} + +func TestParseMailboxes_DropsEmptyEntries(t *testing.T) { + got := parseMailboxes("alice@x,,bob@x,") + want := []string{"alice@x", "bob@x"} + if len(got) != len(want) { + t.Fatalf("want %v, got %v", want, got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("[%d] want %q, got %q", i, want[i], got[i]) + } + } +} + +// ---- mock APIClient for PreConsume tests ---- + +type mockCall struct { + method string + path string + body interface{} +} + +type mockAPIClient struct { + calls []mockCall + failAt int // 1-indexed; 0 = never fail + failErr error +} + +func (m *mockAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) { + m.calls = append(m.calls, mockCall{method: method, path: path, body: body}) + if m.failAt > 0 && len(m.calls) == m.failAt { + return nil, m.failErr + } + return json.RawMessage(`{"code":0}`), nil +} + +// ---- PreConsume tests ---- + +func TestMailMessageReceivedPreConsume_SingleMailboxHappy(t *testing.T) { + mc := &mockAPIClient{} + cleanup, err := mailMessageReceivedPreConsume(context.Background(), mc, map[string]string{"mailbox": "alice@x"}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if cleanup == nil { + t.Fatal("expected non-nil cleanup") + } + if len(mc.calls) != 1 { + t.Fatalf("expected 1 call, got %d", len(mc.calls)) + } + // url.PathEscape leaves @ unencoded (valid in path segments per RFC 3986) + if !strings.Contains(mc.calls[0].path, "alice@x") || !strings.Contains(mc.calls[0].path, "subscribe") { + t.Errorf("unexpected subscribe path: %s", mc.calls[0].path) + } +} + +func TestMailMessageReceivedPreConsume_MultiMailboxHappy(t *testing.T) { + mc := &mockAPIClient{} + cleanup, err := mailMessageReceivedPreConsume(context.Background(), mc, map[string]string{"mailbox": "a@x,b@x,c@x"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cleanup == nil { + t.Fatal("expected cleanup") + } + if len(mc.calls) != 3 { + t.Fatalf("expected 3 subscribe calls, got %d: %v", len(mc.calls), mc.calls) + } + // Verify order: a → b → c (url.PathEscape leaves @ unencoded) + for i, want := range []string{"a@x", "b@x", "c@x"} { + if !strings.Contains(mc.calls[i].path, want) || !strings.Contains(mc.calls[i].path, "subscribe") { + t.Errorf("call[%d] path %q should contain %q/subscribe", i, mc.calls[i].path, want) + } + } +} + +func TestMailMessageReceivedPreConsume_PartialFailureRollsBack(t *testing.T) { + // Third call (carol) fails; rollback: bob/unsub → alice/unsub + mc := &mockAPIClient{failAt: 3, failErr: errors.New("boom")} + cleanup, err := mailMessageReceivedPreConsume(context.Background(), mc, map[string]string{"mailbox": "alice@x,bob@x,carol@x"}) + if err == nil { + t.Fatal("expected error") + } + if cleanup != nil { + t.Fatal("expected nil cleanup on failure") + } + if !strings.Contains(err.Error(), "carol@x") { + t.Errorf("error should mention carol@x: %v", err) + } + if !strings.Contains(err.Error(), "mail event subscribe failed") { + t.Errorf("error text mismatch: %v", err) + } + // calls: alice/sub, bob/sub, carol/sub(fail), bob/unsub, alice/unsub + if len(mc.calls) != 5 { + t.Fatalf("expected 5 calls (3 sub + 2 rollback), got %d: %v", len(mc.calls), mc.calls) + } + // rollback in reverse: bob then alice (url.PathEscape leaves @ unencoded) + if !strings.Contains(mc.calls[3].path, "bob@x") || !strings.Contains(mc.calls[3].path, "unsubscribe") { + t.Errorf("rollback call[3] should be bob/unsubscribe, got %q", mc.calls[3].path) + } + if !strings.Contains(mc.calls[4].path, "alice@x") || !strings.Contains(mc.calls[4].path, "unsubscribe") { + t.Errorf("rollback call[4] should be alice/unsubscribe, got %q", mc.calls[4].path) + } +} + +func TestMailMessageReceivedPreConsume_FirstFailureNoRollback(t *testing.T) { + mc := &mockAPIClient{failAt: 1, failErr: errors.New("denied")} + cleanup, err := mailMessageReceivedPreConsume(context.Background(), mc, map[string]string{"mailbox": "alice@x"}) + if err == nil { + t.Fatal("expected error") + } + if cleanup != nil { + t.Fatal("expected nil cleanup") + } + // Only 1 call: alice/subscribe (fail). No rollback since subscribed list is empty. + if len(mc.calls) != 1 { + t.Fatalf("expected 1 call, got %d: %v", len(mc.calls), mc.calls) + } +} + +func TestMailMessageReceivedPreConsume_RollbackUnsubscribeFailureSwallowed(t *testing.T) { + // 2nd subscribe fails; rollback: alice/unsub also fails (should be swallowed). + callCount := 0 + mc := &mockAPIClient{} + mc.failAt = 0 // use custom logic below + + // Build a custom mock: call 1 (alice/sub) OK, call 2 (bob/sub) FAIL, call 3 (alice/unsub rollback) FAIL + mc2 := &customFailMockAPIClient{ + failCalls: map[int]error{ + 2: errors.New("bob subscribe failed"), + 3: errors.New("rollback unsubscribe also failed"), + }, + } + _ = callCount + + cleanup, err := mailMessageReceivedPreConsume(context.Background(), mc2, map[string]string{"mailbox": "alice@x,bob@x"}) + if err == nil { + t.Fatal("expected error from bob subscribe failure") + } + if cleanup != nil { + t.Fatal("expected nil cleanup") + } + if !strings.Contains(err.Error(), "bob@x") { + t.Errorf("error should mention bob@x: %v", err) + } + // 3 calls: alice/sub, bob/sub(fail), alice/unsub(fail, swallowed) + if len(mc2.calls) != 3 { + t.Fatalf("expected 3 calls, got %d", len(mc2.calls)) + } +} + +func TestMailMessageReceivedPreConsume_CleanupReverseOrder(t *testing.T) { + mc := &mockAPIClient{} + cleanup, err := mailMessageReceivedPreConsume(context.Background(), mc, map[string]string{"mailbox": "a@x,b@x,c@x"}) + if err != nil || cleanup == nil { + t.Fatalf("setup failed: err=%v", err) + } + callsBefore := len(mc.calls) // 3 subscribe calls + cleanup() + cleanupCalls := mc.calls[callsBefore:] + if len(cleanupCalls) != 3 { + t.Fatalf("expected 3 cleanup calls, got %d", len(cleanupCalls)) + } + // Reverse order: c → b → a (url.PathEscape leaves @ unencoded) + for i, want := range []string{"c@x", "b@x", "a@x"} { + if !strings.Contains(cleanupCalls[i].path, want) || !strings.Contains(cleanupCalls[i].path, "unsubscribe") { + t.Errorf("cleanup[%d] path %q should contain %q/unsubscribe", i, cleanupCalls[i].path, want) + } + } +} + +func TestMailMessageReceivedPreConsume_CleanupIndependentFailures(t *testing.T) { + // All subscribes succeed; cleanup of b fails but a and c should still be called. + mc := &customFailMockAPIClient{ + failCalls: map[int]error{ + 5: errors.New("b unsubscribe fail"), // call 4=a/sub, 5=b/sub, 6=c/sub (subscribe), then c=7,b=8,a=9 for cleanup + }, + } + cleanup, err := mailMessageReceivedPreConsume(context.Background(), mc, map[string]string{"mailbox": "a@x,b@x,c@x"}) + if err != nil || cleanup == nil { + t.Fatalf("setup failed: err=%v", err) + } + // Override failCalls to fail the b unsubscribe in cleanup (2nd unsubscribe, i.e., the 5th overall call) + mc.failCalls = map[int]error{ + 5: errors.New("b cleanup fail"), + } + cleanup() + // All 3 cleanup unsubscribes must be attempted despite one failing + if len(mc.calls) != 6 { + t.Fatalf("expected 6 calls (3 sub + 3 cleanup), got %d", len(mc.calls)) + } +} + +func TestMailMessageReceivedPreConsume_DefaultMailboxMe(t *testing.T) { + // Passing empty mailbox param should default to "me" via parseMailboxes + mc := &mockAPIClient{} + cleanup, err := mailMessageReceivedPreConsume(context.Background(), mc, map[string]string{"mailbox": ""}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cleanup == nil { + t.Fatal("expected cleanup") + } + if len(mc.calls) != 1 || !strings.Contains(mc.calls[0].path, "/me/") { + t.Errorf("expected subscribe call to /me/, got %v", mc.calls) + } +} + +// ---- processMailMessageReceived ---- + +func TestProcessMailMessageReceived_FlatShape(t *testing.T) { + payload := `{ + "header": { + "event_id": "ev_001", + "event_type": "mail.user_mailbox.event.message_received_v1", + "create_time": "1700000000000" + }, + "event": { + "mail_address": "alice@example.com", + "message_id": "msg_abc", + "sender": "bob@example.com", + "subject": "Hello", + "body": "World" + } + }` + raw := &event.RawEvent{ + EventID: "ev_001", + EventType: mailEventType, + Payload: json.RawMessage(payload), + Timestamp: time.Now(), + } + got, err := processMailMessageReceived(context.Background(), nil, raw, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var out MailMessageReceivedOutput + if err := json.Unmarshal(got, &out); err != nil { + t.Fatalf("unmarshal failed: %v\njson=%s", err, string(got)) + } + if out.Type != "mail.user_mailbox.event.message_received_v1" { + t.Errorf("Type = %q", out.Type) + } + if out.EventID != "ev_001" { + t.Errorf("EventID = %q", out.EventID) + } + if out.Timestamp != "1700000000000" { + t.Errorf("Timestamp = %q", out.Timestamp) + } + if out.Mailbox != "alice@example.com" { + t.Errorf("Mailbox = %q", out.Mailbox) + } + if out.MessageID != "msg_abc" { + t.Errorf("MessageID = %q", out.MessageID) + } + if out.Sender != "bob@example.com" { + t.Errorf("Sender = %q", out.Sender) + } + if out.Subject != "Hello" { + t.Errorf("Subject = %q", out.Subject) + } + if out.BodyExcerpt != "World" { + t.Errorf("BodyExcerpt = %q", out.BodyExcerpt) + } +} + +func TestProcessMailMessageReceived_BodyExcerptTruncates140(t *testing.T) { + body := strings.Repeat("x", 200) + payload := fmt.Sprintf(`{ + "header": {"event_id": "ev_002", "event_type": "mail.user_mailbox.event.message_received_v1", "create_time": ""}, + "event": {"mail_address": "a@b.com", "message_id": "", "sender": "", "subject": "", "body": %q} + }`, body) + raw := &event.RawEvent{ + EventID: "ev_002", + EventType: mailEventType, + Payload: json.RawMessage(payload), + Timestamp: time.Now(), + } + got, err := processMailMessageReceived(context.Background(), nil, raw, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var out MailMessageReceivedOutput + if err := json.Unmarshal(got, &out); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + if len(out.BodyExcerpt) != 140 { + t.Errorf("BodyExcerpt length = %d, want 140", len(out.BodyExcerpt)) + } +} + +func TestProcessMailMessageReceived_MalformedPayloadPassthrough(t *testing.T) { + malformed := json.RawMessage(`not valid json`) + raw := &event.RawEvent{ + EventID: "ev_bad", + EventType: mailEventType, + Payload: malformed, + Timestamp: time.Now(), + } + got, err := processMailMessageReceived(context.Background(), nil, raw, nil) + if err != nil { + t.Fatalf("expected nil error on malformed payload, got %v", err) + } + if string(got) != string(malformed) { + t.Errorf("expected passthrough of malformed payload, got %q", string(got)) + } +} + +// ---- helpers ---- + +type customFailMockAPIClient struct { + calls []mockCall + failCalls map[int]error // 1-indexed call number → error to return +} + +func (m *customFailMockAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) { + m.calls = append(m.calls, mockCall{method: method, path: path, body: body}) + if err, ok := m.failCalls[len(m.calls)]; ok { + return nil, err + } + return json.RawMessage(`{"code":0}`), nil +} diff --git a/events/mail/register.go b/events/mail/register.go new file mode 100644 index 000000000..c53d0ce3e --- /dev/null +++ b/events/mail/register.go @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package mail registers mail-domain EventKeys. +package mail + +import ( + "reflect" + + "github.com/larksuite/cli/internal/event" +) + +// Keys returns all mail-domain EventKey definitions. +// MUST stay in sync with shortcuts/mail/mail_watch.go:98 Scopes field +// (single source of truth: identical 7 items, same order). +func Keys() []event.KeyDefinition { + return []event.KeyDefinition{ + { + Key: mailEventType, + DisplayName: "Receive mail", + Description: "Receive new mail events for one or more mailboxes (comma-separated --param mailbox)", + EventType: mailEventType, + Params: []event.ParamDef{ + { + Name: "mailbox", + Type: event.ParamString, + Required: false, + Default: "me", + Description: "mailbox email address(es); comma-separated for multi (e.g. alice@x.com,bob@x.com); default 'me' for the primary mailbox of the logged-in user", + }, + }, + Schema: event.SchemaDef{ + Custom: &event.SchemaSpec{Type: reflect.TypeOf(MailMessageReceivedOutput{})}, + }, + Process: processMailMessageReceived, + PreConsume: mailMessageReceivedPreConsume, + // MUST stay in sync with shortcuts/mail/mail_watch.go:98 (single + // source of truth; same 7 items, same order). mail +watch and + // this EventKey require the exact same scope set. + 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"}, + RequiredConsoleEvents: []string{mailEventType}, + }, + } +} diff --git a/events/register.go b/events/register.go index 7ca984a0f..0eed8fe55 100644 --- a/events/register.go +++ b/events/register.go @@ -6,13 +6,14 @@ package events import ( "github.com/larksuite/cli/events/im" + "github.com/larksuite/cli/events/mail" "github.com/larksuite/cli/internal/event" ) -// Mail is intentionally omitted: only IM is wired up this phase. func init() { all := [][]event.KeyDefinition{ im.Keys(), + mail.Keys(), } for _, keys := range all { for _, k := range keys { diff --git a/events/register_test.go b/events/register_test.go new file mode 100644 index 000000000..317ced06c --- /dev/null +++ b/events/register_test.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package events + +import ( + "testing" + + "github.com/larksuite/cli/internal/event" +) + +// TestMailKeysWiredUp proves that events/register.go correctly wires up the +// mail domain Keys() into the global registry. This acts as a regression guard +// preventing accidental deletion of the mail.Keys() call in init(). +func TestMailKeysWiredUp(t *testing.T) { + def, ok := event.Lookup("mail.user_mailbox.event.message_received_v1") + if !ok || def == nil { + t.Fatal("mail EventKey 'mail.user_mailbox.event.message_received_v1' not registered; " + + "check events/register.go for mail.Keys() wire-up") + } + if def.EventType != def.Key { + t.Errorf("EventType %q != Key %q", def.EventType, def.Key) + } +} diff --git a/shortcuts/mail/mail_watch.go b/shortcuts/mail/mail_watch.go index 00629bfc6..834b13d98 100644 --- a/shortcuts/mail/mail_watch.go +++ b/shortcuts/mail/mail_watch.go @@ -24,6 +24,7 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/lockfile" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/vfs" @@ -93,7 +94,7 @@ func detectPromptInjection(content string) bool { 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.", + 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. For unified subscription with console preflight, consider 'lark-cli event consume mail.user_mailbox.event.message_received_v1 --param mailbox=[,...] --as user'; mail +watch keeps providing label/folder filter and msg-format multi-mode and remains supported. Acquires the same app-level subscribe lock as event +subscribe; use --force to bypass (NOT recommended).", 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"}, @@ -107,6 +108,7 @@ var MailWatch = common.Shortcut{ {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)"}, + {Name: "force", Type: "bool", Default: "false", Desc: "bypass appID-level subscribe lock; allow concurrent subscribe-class processes for the same app (NOT recommended; concurrent unsubscribe may cancel each other's server-side subscription)"}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { mailbox := resolveMailboxID(runtime) @@ -181,8 +183,45 @@ var MailWatch = common.Shortcut{ printWatchOutputSchema(runtime) return nil } + + // [NEW] appID-level single-instance lock; shared with event +subscribe + // (Hidden) via the same lockfile.ForSubscribe(appID) implementation + // so cross-command concurrent subscribes are mutually excluded. + // event consume mail.user_mailbox.event.message_received_v1 does NOT + // hold this lock (PreConsume cannot access appID through APIClient); + // see R12 for the partial-mitigation analysis. + if !runtime.Bool("force") { + lock, err := lockfile.ForSubscribe(runtime.Config.AppID) + if err != nil { + return fmt.Errorf("failed to create lock: %w", err) + } + if err := lock.TryLock(); err != nil { + return output.ErrValidation( + "another subscribe-class instance is already running for app %s\n"+ + " Only one subscribe-class process per app is allowed to prevent concurrent unsubscribe canceling each other's server-side subscription.\n"+ + " Recommended: use 'lark-cli event consume mail.user_mailbox.event.message_received_v1 --as user' for unified entry with console preflight.\n"+ + " Or pass --force to bypass this check (NOT recommended).", + runtime.Config.AppID, + ) + } + defer lock.Unlock() + } + mailbox := resolveMailboxID(runtime) hintIdentityFirst(runtime, mailbox) + + // [NEW] Migration hint — recommend unified event consume entrypoint. + errOut := runtime.IO().ErrOut + out := runtime.IO().Out + + info := func(msg string) { + fmt.Fprintln(errOut, msg) + } + + info("Tip: prefer 'lark-cli event consume " + mailEventType + + " --param mailbox=[,...] --as user' for unified entry with console preflight. " + + "mail +watch keeps providing label/folder filter and msg-format multi-mode, and remains supported.") + outFormat := runtime.Str("format") switch outFormat { case "json", "data", "": @@ -215,13 +254,6 @@ var MailWatch = common.Shortcut{ 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 { diff --git a/shortcuts/mail/mail_watch_event_consume_test.go b/shortcuts/mail/mail_watch_event_consume_test.go new file mode 100644 index 000000000..fb560dae4 --- /dev/null +++ b/shortcuts/mail/mail_watch_event_consume_test.go @@ -0,0 +1,116 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "strings" + "testing" +) + +// TestMailWatch_DescriptionContainsMigrationTip ensures the Description field +// mentions 'event consume mail.user_mailbox.event.message_received_v1' and +// '--as user'. This is a v9 regression guard. +func TestMailWatch_DescriptionContainsMigrationTip(t *testing.T) { + desc := MailWatch.Description + if !strings.Contains(desc, "event consume mail.user_mailbox.event.message_received_v1") { + t.Errorf("Description should mention 'event consume mail.user_mailbox.event.message_received_v1', got: %q", desc) + } + if !strings.Contains(desc, "--as user") { + t.Errorf("Description should mention '--as user', got: %q", desc) + } +} + +// TestMailWatch_ForceFlagAvailableInHelp ensures the --force flag is defined +// in MailWatch.Flags, is of type "bool", and defaults to false. +func TestMailWatch_ForceFlagAvailableInHelp(t *testing.T) { + var found bool + for _, fl := range MailWatch.Flags { + if fl.Name == "force" { + found = true + if fl.Type != "bool" { + t.Errorf("--force flag type = %q, want \"bool\"", fl.Type) + } + // Default should be "false" or empty (interpreted as false) + if fl.Default != "false" && fl.Default != "" { + t.Errorf("--force flag default = %q, want \"false\" or \"\"", fl.Default) + } + break + } + } + if !found { + t.Error("--force flag not found in MailWatch.Flags") + } +} + +// TestMailWatch_HintDoesNotMentionDeprecatedSubscribe ensures the --force +// flag description does not reference 'event +subscribe' (v9 regression guard). +func TestMailWatch_HintDoesNotMentionDeprecatedSubscribe(t *testing.T) { + for _, fl := range MailWatch.Flags { + if fl.Name == "force" { + if strings.Contains(fl.Desc, "event +subscribe") { + t.Errorf("--force flag Desc should NOT mention 'event +subscribe', got: %q", fl.Desc) + } + return + } + } + // flag not found — TestMailWatch_ForceFlagAvailableInHelp handles that +} + +// TestMailWatch_PrintSchemaDoesNotRequireForce verifies that --print-output-schema +// works even when the --force flag is set to its default (false). This is a +// static check that print-output-schema runs the early-return path before the +// lockfile block. +func TestMailWatch_PrintSchemaDoesNotRequireForce(t *testing.T) { + // Verify the Flags slice has print-output-schema before force in the intended + // semantics: print-output-schema short-circuits before the lockfile block. + printSchemaIdx := -1 + forceIdx := -1 + for i, fl := range MailWatch.Flags { + switch fl.Name { + case "print-output-schema": + printSchemaIdx = i + case "force": + forceIdx = i + } + } + if printSchemaIdx < 0 { + t.Error("--print-output-schema flag not found") + } + if forceIdx < 0 { + t.Error("--force flag not found") + } + // Both must be present; no ordering constraint is asserted beyond existence. +} + +// TestMailWatch_ScopesCount verifies MailWatch.Scopes has exactly 7 items, +// consistent with the single source of truth for the 7 mail user scopes. +func TestMailWatch_ScopesCount(t *testing.T) { + if len(MailWatch.Scopes) != 7 { + t.Errorf("MailWatch.Scopes: expected 7 items, got %d: %v", len(MailWatch.Scopes), MailWatch.Scopes) + } +} + +// TestMailWatch_ScopesMatchEventKeyScopes ensures MailWatch.Scopes is aligned +// with the expected 7-item scope list, preventing scope drift between mail +watch +// and event consume mail.user_mailbox.event.message_received_v1. +func TestMailWatch_ScopesMatchEventKeyScopes(t *testing.T) { + want := []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", + } + got := MailWatch.Scopes + if len(got) != len(want) { + t.Fatalf("scope count mismatch: want %d, got %d", len(want), len(got)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("Scopes[%d]: want %q, got %q", i, want[i], got[i]) + } + } +} diff --git a/skills/lark-event/SKILL.md b/skills/lark-event/SKILL.md index c015fe837..19f436cd4 100644 --- a/skills/lark-event/SKILL.md +++ b/skills/lark-event/SKILL.md @@ -53,6 +53,10 @@ lark-cli event consume im.message.receive_v1 --as bot > receive.ndjson lark-cli event consume im.message.reaction.created_v1 --as bot > reaction.ndjson & wait +# Receive new mail for one or more mailboxes (comma-separated); requires --as user +# PreConsume automatically subscribes each mailbox via OAPI and unsubscribes on exit +lark-cli event consume mail.user_mailbox.event.message_received_v1 --param mailbox=alice@x.com,bob@x.com --as user + ``` ## Call flow @@ -143,3 +147,4 @@ Lark-defined semantic tags (**not** JSON Schema's standard `format`). Common val | Topic | Reference | Coverage | |---|---|---| | IM | [`references/lark-event-im.md`](references/lark-event-im.md) | Catalog of 11 IM EventKeys + shape notes (flat vs V2 envelope) + `im.message.receive_v1` field gotchas (`sender_id` is open_id only; `.content` is plain text except for `interactive` cards) + common jq recipes (filter by chat_type / message_type / sender) | +| Mail | `events/mail/` (mail.user_mailbox.event.message_received_v1) | Receive new mail across one or more mailboxes; 7 user scopes; PreConsume opens mailbox business subscribe + cleanup reverse unsubscribe | diff --git a/skills/lark-mail/SKILL.md b/skills/lark-mail/SKILL.md index e80811934..1f3265513 100644 --- a/skills/lark-mail/SKILL.md +++ b/skills/lark-mail/SKILL.md @@ -459,7 +459,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli mail + [flags]`) | [`+messages`](references/lark-mail-messages.md) | Use when reading full content for multiple emails by message ID. Prefer this shortcut over calling raw mail user_mailbox.messages batch_get directly, because it base64url-decodes body fields and returns normalized per-message output that is easier to consume. | | [`+thread`](references/lark-mail-thread.md) | Use when querying a full mail conversation/thread by thread ID. Returns all messages in chronological order, including replies and drafts, with body content and attachments metadata, including inline images. | | [`+triage`](references/lark-mail-triage.md) | List mail summaries (date/from/subject/message_id). Use --query for full-text search, --filter for exact-match conditions. | -| [`+watch`](references/lark-mail-watch.md) | 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. | +| [`+watch`](references/lark-mail-watch.md) | 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. Startup now prints a tip recommending `lark-cli event consume mail.user_mailbox.event.message_received_v1 --param mailbox=[,...] --as user` as the unified entry with console preflight (auto-detects whether the app has subscribed mail event in console and whether the user token has the required 7 mail scopes). mail +watch keeps providing label/folder filter (--labels/--folders/--label-ids/--folder-ids) and msg-format multi-mode (--msg-format metadata\|minimal\|plain_text_full\|event\|full) and remains supported for those features. mail +watch and event +subscribe now share an appID-level subscribe lock (subscribe_.lock) — running both concurrently for the same app is rejected unless --force is passed. event consume does NOT hold this lock (framework limitation); avoid running event consume mail.user_mailbox.event.message_received_v1 concurrently with mail +watch. | | [`+reply`](references/lark-mail-reply.md) | Reply to a message and save as draft (default). Use --confirm-send to send immediately after user confirmation. Sets Re: subject, In-Reply-To, and References headers automatically. | | [`+reply-all`](references/lark-mail-reply-all.md) | Reply to all recipients and save as draft (default). Use --confirm-send to send immediately after user confirmation. Includes all original To and CC automatically. | | [`+send`](references/lark-mail-send.md) | Compose a new email and save as draft (default). Use --confirm-send to send immediately after user confirmation. |