@startsimpli/integrations 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@startsimpli/integrations",
3
+ "version": "0.1.0",
4
+ "description": "Provider-agnostic types, API clients, hooks, and UI primitives for email + calendar integrations across StartSimpli apps.",
5
+ "main": "./src/index.ts",
6
+ "types": "./src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./types": "./src/types/index.ts",
10
+ "./api": "./src/api/index.ts"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "README.md"
15
+ ],
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "scripts": {
20
+ "type-check": "tsc --noEmit",
21
+ "test": "vitest run",
22
+ "test:watch": "vitest"
23
+ },
24
+ "dependencies": {},
25
+ "peerDependencies": {
26
+ "react": ">=18.0.0",
27
+ "@tanstack/react-query": ">=5.0.0"
28
+ },
29
+ "peerDependenciesMeta": {
30
+ "react": { "optional": true },
31
+ "@tanstack/react-query": { "optional": true }
32
+ },
33
+ "devDependencies": {
34
+ "@tanstack/react-query": "^5.0.0",
35
+ "@types/node": "^22.0.0",
36
+ "@types/react": "^19.0.0",
37
+ "msw": "^2.0.0",
38
+ "react": "^19.0.0",
39
+ "typescript": "^6.0.0",
40
+ "vitest": "^3.0.0"
41
+ }
42
+ }
@@ -0,0 +1,160 @@
1
+ # `@startsimpli/integrations` — Test Contract
2
+
3
+ Reference bead: **raise-simpli-jhb**. Blocks **raise-simpli-3ry** (package impl).
4
+
5
+ This file pins the comprehensive test contract for the package. The package
6
+ directory is not yet bootstrapped (no `package.json`, no vitest config, no
7
+ source). When raise-simpli-3ry lands, every section below MUST be implemented
8
+ as a runnable vitest spec under `packages/integrations/src/__tests__/`.
9
+
10
+ ---
11
+
12
+ ## Infrastructure to stand up
13
+
14
+ - `packages/integrations/package.json` — name `@startsimpli/integrations`,
15
+ dependencies on `react`, `@tanstack/react-query`, `msw`, `vitest`,
16
+ `@testing-library/react`, `@testing-library/jest-dom`, `jsdom`.
17
+ - `packages/integrations/vitest.config.ts` — environment `jsdom`, setup file
18
+ that registers msw handlers + `@testing-library/jest-dom/vitest`.
19
+ - `packages/integrations/src/__tests__/handlers.ts` — msw mock handlers for
20
+ `/api/v1/auth/oauth/google/*`, `/api/v1/auth/oauth/microsoft/*`,
21
+ `/api/v1/calendar/events/`, `/api/v1/email/messages/`,
22
+ `/api/v1/destinations/{id}/run/`, `/api/v1/oauth-accounts/`.
23
+
24
+ ---
25
+
26
+ ## 1. `types.test.ts` — type round-trips
27
+
28
+ - `ConnectedAccount` accepts a payload with the exact keys returned by Django:
29
+ `id, provider ("google"|"microsoft"), email, scopes, has_calendar_scope,
30
+ has_mail_scope, needs_reauth, connected_at`.
31
+ - `CalendarEvent` matches the `CalendarEventSerializer` output:
32
+ `id, provider, calendar_id, provider_event_id, start_at, end_at, title,
33
+ description, location, attendees, status, recurrence_rule`.
34
+ - `EmailMessage` matches `EmailMessageSerializer` output.
35
+ - `attendees` array elements: `{ email, name?, status? }`.
36
+
37
+ ## 2. `api/oauth.test.ts`
38
+
39
+ - `connectProvider("google")` POSTs to `/api/v1/auth/oauth/google/initiate/`,
40
+ returns `{ authorization_url }`.
41
+ - `connectProvider("microsoft")` POSTs to `/api/v1/auth/oauth/microsoft/initiate/`.
42
+ - `revokeAccount(id)` POSTs to the right `revoke` endpoint per provider.
43
+
44
+ ## 3. `api/calendar.test.ts`
45
+
46
+ - `listCalendarEvents({ from, to })` GETs `/api/v1/calendar/events/?from=&to=`.
47
+ - `listCalendarEvents({ from, to, provider: "microsoft" })` adds `&provider=microsoft`.
48
+ - 200 response is parsed into `CalendarEvent[]` matching the type contract.
49
+ - 401 throws an `UnauthenticatedError`.
50
+ - 403 with `reconsent_url` throws a `ReconsentRequiredError` carrying the URL.
51
+
52
+ ## 4. `api/email.test.ts`
53
+
54
+ - `listEmailMessages({ since, account_id, is_outbound })` builds the right
55
+ query string.
56
+ - Pagination: response includes `next` URL → typed `nextCursor`.
57
+
58
+ ## 5. `hooks/useConnectedAccounts.test.ts`
59
+
60
+ - Renders, hits `/api/v1/oauth-accounts/`, exposes `data, isLoading, error`.
61
+ - On disconnect, optimistically removes the row + invalidates query.
62
+
63
+ ## 6. `hooks/useCalendarEvents.test.ts`
64
+
65
+ - Window-based query key: `useCalendarEvents({ from, to })` keys on the window.
66
+ - Window change re-fetches.
67
+ - 403 with reconsent → exposes `reconsentUrl` separately from data.
68
+ - Loading / error / data states each render in isolation.
69
+
70
+ ## 7. `hooks/useEmailMessages.test.ts`
71
+
72
+ - Filter object becomes query params.
73
+ - Cursor pagination: `fetchNextPage()` chains.
74
+
75
+ ## 8. `hooks/useReconsent.test.ts`
76
+
77
+ - Triggered on any 403 response with `reconsent_url`.
78
+ - Clicking the prompt opens the auth URL and on return invalidates the
79
+ affected queries.
80
+
81
+ ## 9. `components/ConnectAccountCard.test.tsx`
82
+
83
+ - Renders provider name, status badge, primary CTA.
84
+ - Status="disconnected" → "Connect" button calls `connectProvider`.
85
+ - Status="connected" → "Disconnect" button.
86
+ - Status="needs_reauth" → "Reconnect" button + warning state.
87
+ - Per-provider scope summary (Mail + Calendar).
88
+
89
+ ## 10. `components/EventList.test.tsx`
90
+
91
+ - Empty state when `events.length === 0`.
92
+ - Loading state.
93
+ - Error state when `error` set.
94
+ - Renders one `<EventListItem />` per event.
95
+
96
+ ## 11. `components/EventListItem.test.tsx`
97
+
98
+ - Title, time range, location, attendee count.
99
+ - Status="cancelled" renders strikethrough / cancelled badge.
100
+
101
+ ## 12. `components/EmailList.test.tsx`
102
+
103
+ - Threading: groups by `thread_id`.
104
+ - Outbound vs inbound visual distinction.
105
+ - Empty / loading / error states.
106
+
107
+ ## 13. `components/ReconsentPromptCard.test.tsx`
108
+
109
+ - Renders provider name + missing scopes.
110
+ - "Reconnect" button hits `connectProvider` with the right scope set.
111
+
112
+ ## 14. `components/AccountStatusBadge.test.tsx`
113
+
114
+ - One badge variant per status value.
115
+
116
+ ## 15. Integration: `__tests__/connectAccount.flow.test.ts`
117
+
118
+ - Click `<ConnectAccountCard />` Connect button → `connectProvider` returns
119
+ authorization_url → window.location is set (mocked).
120
+ - After callback simulation, `useConnectedAccounts` returns the new account.
121
+
122
+ ## 16. Integration: `__tests__/calendarOverlay.flow.test.ts`
123
+
124
+ - Page mounts `<EventList />` with `useCalendarEvents` window prop.
125
+ - msw returns events → list renders.
126
+ - msw returns 403 with reconsent_url → `<ReconsentPromptCard />` renders.
127
+ - Click reconsent → connectProvider called with augmented scopes.
128
+
129
+ ## 17. Integration: `__tests__/sendEmail.flow.test.ts`
130
+
131
+ - `sendEmail({ to, subject, body })` POSTs to `/api/v1/destinations/{id}/run/`.
132
+ - Returns the new `DestinationRun` id.
133
+ - Polling status until `completed` works.
134
+
135
+ ## 18. Integration: `__tests__/createCalendarEvent.flow.test.ts`
136
+
137
+ - `createCalendarEvent({ provider, ... })` POSTs to the right destination run.
138
+ - Returns provider event id.
139
+ - Round-trips: subsequent `useCalendarEvents` shows the event.
140
+
141
+ ## 19. Cross-provider parity tests
142
+
143
+ - Same flow tested for `provider="google"` and `provider="microsoft"`:
144
+ connect, list events, list emails, send, create event.
145
+
146
+ ## 20. Error handling parity
147
+
148
+ - 401 → throw + redirect to login (or expose error).
149
+ - 403 + reconsent_url → reconsent flow.
150
+ - 5xx → typed transient error, retry-with-backoff hook.
151
+
152
+ ---
153
+
154
+ ## Acceptance for raise-simpli-jhb close (when impl bead lands)
155
+
156
+ - `pnpm --filter @startsimpli/integrations test` exits 0.
157
+ - All 20 sections above have at least one runnable spec.
158
+ - msw handlers are the ONLY HTTP mocking layer (no `jest.mock('node:fetch')` etc).
159
+ - `tsc --noEmit` clean.
160
+ - Coverage report: hooks ≥80%, components ≥70%.
@@ -0,0 +1,229 @@
1
+ /**
2
+ * API client tests against a mocked Django backend.
3
+ *
4
+ * Reference: SPEC.md sections 2 (oauth), 3 (calendar), 4 (email).
5
+ */
6
+
7
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
8
+ import { http, HttpResponse } from 'msw';
9
+ import { setupServer } from 'msw/node';
10
+
11
+ import {
12
+ ApiError,
13
+ ReconsentRequiredError,
14
+ UnauthenticatedError,
15
+ } from '../types/index';
16
+ import {
17
+ connectProvider,
18
+ listCalendarEvents,
19
+ listEmailMessages,
20
+ revokeAccount,
21
+ setApiBase,
22
+ } from '../api/index';
23
+
24
+ const BASE = 'https://api.test';
25
+
26
+ const server = setupServer(
27
+ http.post(`${BASE}/api/v1/auth/oauth/microsoft/initiate/`, async () => {
28
+ return HttpResponse.json({
29
+ authorization_url:
30
+ 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?scope=Mail.Read+Calendars.Read',
31
+ });
32
+ }),
33
+ http.post(`${BASE}/api/v1/auth/oauth/google/initiate/`, async () => {
34
+ return HttpResponse.json({
35
+ authorization_url:
36
+ 'https://accounts.google.com/o/oauth2/v2/auth?scope=gmail.readonly+calendar.readonly',
37
+ });
38
+ }),
39
+ http.post(`${BASE}/api/v1/auth/oauth/microsoft/revoke/`, async () => {
40
+ return HttpResponse.json({ status: 'revoked' });
41
+ }),
42
+ http.get(`${BASE}/api/v1/calendar/events/`, ({ request }) => {
43
+ const url = new URL(request.url);
44
+ const provider = url.searchParams.get('provider');
45
+ const from = url.searchParams.get('from');
46
+ const to = url.searchParams.get('to');
47
+ return HttpResponse.json({
48
+ count: 1,
49
+ next: null,
50
+ previous: null,
51
+ results: [
52
+ {
53
+ id: 'evt-1',
54
+ provider: provider || 'microsoft',
55
+ calendar_id: 'primary',
56
+ provider_event_id: 'p-1',
57
+ start_at: from || '2026-04-15T10:00:00Z',
58
+ end_at: to || '2026-04-15T11:00:00Z',
59
+ title: 'Team standup',
60
+ attendees: [{ email: 'a@b.com', status: 'accepted' }],
61
+ status: 'confirmed',
62
+ },
63
+ ],
64
+ });
65
+ }),
66
+ http.get(`${BASE}/api/v1/email/messages/`, () => {
67
+ return HttpResponse.json({
68
+ count: 1,
69
+ next: null,
70
+ previous: null,
71
+ results: [
72
+ {
73
+ id: 'msg-1',
74
+ provider: 'google',
75
+ provider_message_id: 'p-1',
76
+ thread_id: 't-1',
77
+ sender: 'a@b.com',
78
+ recipients: ['u@e.com'],
79
+ subject: 'hi',
80
+ body_preview: 'hello',
81
+ sent_at: null,
82
+ received_at: '2026-04-15T10:00:00Z',
83
+ is_outbound: false,
84
+ matched_contact: null,
85
+ },
86
+ ],
87
+ });
88
+ }),
89
+ );
90
+
91
+ beforeAll(() => {
92
+ setApiBase(`${BASE}/api/v1`);
93
+ server.listen({ onUnhandledRequest: 'error' });
94
+ });
95
+ afterEach(() => server.resetHandlers());
96
+ afterAll(() => server.close());
97
+
98
+
99
+ describe('connectProvider', () => {
100
+ it('POSTs to /auth/oauth/{provider}/initiate/ and returns authorization_url', async () => {
101
+ const ms = await connectProvider('microsoft');
102
+ expect(ms.authorization_url).toContain('login.microsoftonline.com');
103
+ expect(ms.authorization_url).toContain('Mail.Read');
104
+ expect(ms.authorization_url).toContain('Calendars.Read');
105
+
106
+ const g = await connectProvider('google');
107
+ expect(g.authorization_url).toContain('accounts.google.com');
108
+ expect(g.authorization_url).toContain('gmail.readonly');
109
+ expect(g.authorization_url).toContain('calendar.readonly');
110
+ });
111
+
112
+ it('passes redirect_uri through when given', async () => {
113
+ let capturedBody: unknown = null;
114
+ server.use(
115
+ http.post(`${BASE}/api/v1/auth/oauth/microsoft/initiate/`, async ({ request }) => {
116
+ capturedBody = await request.json();
117
+ return HttpResponse.json({ authorization_url: 'x' });
118
+ }),
119
+ );
120
+ await connectProvider('microsoft', 'https://app.example.com/cb');
121
+ expect(capturedBody).toEqual({ redirect_uri: 'https://app.example.com/cb' });
122
+ });
123
+ });
124
+
125
+
126
+ describe('revokeAccount', () => {
127
+ it('POSTs the oauth_account_id and returns status', async () => {
128
+ const result = await revokeAccount('microsoft', 'acct-id-1');
129
+ expect(result.status).toBe('revoked');
130
+ });
131
+ });
132
+
133
+
134
+ describe('listCalendarEvents', () => {
135
+ it('GETs with from + to query params', async () => {
136
+ const from = new Date('2026-04-01T00:00:00Z');
137
+ const to = new Date('2026-04-30T23:59:59Z');
138
+ const result = await listCalendarEvents({ from, to });
139
+ expect(result.results).toHaveLength(1);
140
+ expect(result.results[0].title).toBe('Team standup');
141
+ expect(result.results[0].provider).toBe('microsoft'); // default in handler
142
+ });
143
+
144
+ it('passes provider filter through', async () => {
145
+ let capturedUrl = '';
146
+ server.use(
147
+ http.get(`${BASE}/api/v1/calendar/events/`, ({ request }) => {
148
+ capturedUrl = request.url;
149
+ return HttpResponse.json({ count: 0, next: null, previous: null, results: [] });
150
+ }),
151
+ );
152
+ await listCalendarEvents({
153
+ from: '2026-04-01T00:00:00Z',
154
+ to: '2026-04-30T23:59:59Z',
155
+ provider: 'google',
156
+ });
157
+ expect(capturedUrl).toContain('provider=google');
158
+ expect(capturedUrl).toContain('from=2026-04-01T00%3A00%3A00Z');
159
+ });
160
+ });
161
+
162
+
163
+ describe('listEmailMessages', () => {
164
+ it('GETs with no params returns paginated list', async () => {
165
+ const result = await listEmailMessages();
166
+ expect(result.results).toHaveLength(1);
167
+ expect(result.results[0].subject).toBe('hi');
168
+ });
169
+
170
+ it('passes is_outbound + account_id', async () => {
171
+ let capturedUrl = '';
172
+ server.use(
173
+ http.get(`${BASE}/api/v1/email/messages/`, ({ request }) => {
174
+ capturedUrl = request.url;
175
+ return HttpResponse.json({ count: 0, next: null, previous: null, results: [] });
176
+ }),
177
+ );
178
+ await listEmailMessages({ is_outbound: true, account_id: 'acct-1' });
179
+ expect(capturedUrl).toContain('is_outbound=true');
180
+ expect(capturedUrl).toContain('account_id=acct-1');
181
+ });
182
+ });
183
+
184
+
185
+ describe('error normalisation', () => {
186
+ it('401 surfaces UnauthenticatedError', async () => {
187
+ server.use(
188
+ http.get(`${BASE}/api/v1/calendar/events/`, () => {
189
+ return HttpResponse.json({ detail: 'Authentication required' }, { status: 401 });
190
+ }),
191
+ );
192
+ await expect(
193
+ listCalendarEvents({ from: '2026-04-01T00:00:00Z', to: '2026-04-30T23:59:59Z' }),
194
+ ).rejects.toBeInstanceOf(UnauthenticatedError);
195
+ });
196
+
197
+ it('403 with reconsent_url surfaces ReconsentRequiredError', async () => {
198
+ server.use(
199
+ http.get(`${BASE}/api/v1/calendar/events/`, () => {
200
+ return HttpResponse.json(
201
+ { detail: 'Calendars.Read missing', reconsent_url: 'https://login.microsoftonline.com/...' },
202
+ { status: 403 },
203
+ );
204
+ }),
205
+ );
206
+ try {
207
+ await listCalendarEvents({ from: '2026-04-01T00:00:00Z', to: '2026-04-30T23:59:59Z' });
208
+ throw new Error('expected ReconsentRequiredError');
209
+ } catch (e) {
210
+ expect(e).toBeInstanceOf(ReconsentRequiredError);
211
+ expect((e as ReconsentRequiredError).reconsent_url).toContain('login.microsoftonline');
212
+ }
213
+ });
214
+
215
+ it('5xx surfaces generic ApiError', async () => {
216
+ server.use(
217
+ http.get(`${BASE}/api/v1/calendar/events/`, () => {
218
+ return HttpResponse.json({ detail: 'down' }, { status: 503 });
219
+ }),
220
+ );
221
+ try {
222
+ await listCalendarEvents({ from: '2026-04-01T00:00:00Z', to: '2026-04-30T23:59:59Z' });
223
+ throw new Error('expected ApiError');
224
+ } catch (e) {
225
+ expect(e).toBeInstanceOf(ApiError);
226
+ expect((e as ApiError).status).toBe(503);
227
+ }
228
+ });
229
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Shared fetch wrapper for all @startsimpli/integrations API clients.
3
+ *
4
+ * Handles:
5
+ * - Base URL prefixing (default '/api/v1', overridable via setApiBase()).
6
+ * - JSON encoding + content-type.
7
+ * - Error normalisation: 401 → UnauthenticatedError, 403 with reconsent_url
8
+ * → ReconsentRequiredError, other 4xx/5xx → ApiError.
9
+ */
10
+
11
+ import {
12
+ ApiError,
13
+ ReconsentRequiredError,
14
+ UnauthenticatedError,
15
+ type ApiErrorPayload,
16
+ } from '../types/index';
17
+
18
+ let _apiBase = '/api/v1';
19
+ let _tokenProvider: (() => string | null | Promise<string | null>) | null = null;
20
+
21
+ export function setApiBase(base: string): void {
22
+ _apiBase = base.replace(/\/$/, '');
23
+ }
24
+
25
+ export function getApiBase(): string {
26
+ return _apiBase;
27
+ }
28
+
29
+ /**
30
+ * Wire up an auth token source. The consumer app calls this once at startup;
31
+ * subsequent requests pull a fresh token before each call and inject it as
32
+ * `Authorization: Bearer <token>`.
33
+ *
34
+ * Example (raise-simpli):
35
+ * import { setAuthTokenProvider } from '@startsimpli/integrations'
36
+ * import { getAccessToken } from '@startsimpli/auth/client'
37
+ * setAuthTokenProvider(() => getAccessToken())
38
+ */
39
+ export function setAuthTokenProvider(
40
+ provider: (() => string | null | Promise<string | null>) | null,
41
+ ): void {
42
+ _tokenProvider = provider;
43
+ }
44
+
45
+ interface RequestOptions {
46
+ method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
47
+ body?: unknown;
48
+ query?: Record<string, string | number | boolean | undefined | null>;
49
+ headers?: Record<string, string>;
50
+ }
51
+
52
+ export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
53
+ const { method = 'GET', body, query, headers = {} } = opts;
54
+ let url = `${_apiBase}${path.startsWith('/') ? path : '/' + path}`;
55
+ if (query) {
56
+ const params = new URLSearchParams();
57
+ for (const [k, v] of Object.entries(query)) {
58
+ if (v === undefined || v === null) continue;
59
+ params.set(k, String(v));
60
+ }
61
+ const qs = params.toString();
62
+ if (qs) url += `?${qs}`;
63
+ }
64
+
65
+ const authHeaders: Record<string, string> = {};
66
+ if (_tokenProvider) {
67
+ try {
68
+ const token = await _tokenProvider();
69
+ if (token) authHeaders.Authorization = `Bearer ${token}`;
70
+ } catch {
71
+ // Token provider failure: surface as Unauthenticated below if Django rejects.
72
+ }
73
+ }
74
+
75
+ const init: RequestInit = {
76
+ method,
77
+ headers: {
78
+ Accept: 'application/json',
79
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
80
+ ...authHeaders,
81
+ ...headers,
82
+ },
83
+ credentials: 'include',
84
+ };
85
+ if (body !== undefined) init.body = JSON.stringify(body);
86
+
87
+ const resp = await fetch(url, init);
88
+ const contentType = resp.headers.get('Content-Type') || '';
89
+ let payload: unknown = null;
90
+ if (contentType.includes('application/json')) {
91
+ try {
92
+ payload = await resp.json();
93
+ } catch {
94
+ payload = null;
95
+ }
96
+ } else {
97
+ try {
98
+ payload = await resp.text();
99
+ } catch {
100
+ payload = null;
101
+ }
102
+ }
103
+
104
+ if (!resp.ok) {
105
+ const errorPayload: ApiErrorPayload =
106
+ payload && typeof payload === 'object' ? (payload as ApiErrorPayload) : {};
107
+ if (resp.status === 401) {
108
+ throw new UnauthenticatedError(errorPayload);
109
+ }
110
+ if (resp.status === 403 && errorPayload.reconsent_url) {
111
+ throw new ReconsentRequiredError(errorPayload);
112
+ }
113
+ throw new ApiError(
114
+ errorPayload.error || errorPayload.detail || `HTTP ${resp.status}`,
115
+ resp.status,
116
+ errorPayload,
117
+ );
118
+ }
119
+
120
+ return payload as T;
121
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Connected OAuth accounts API client.
3
+ *
4
+ * Backed by GET /api/v1/auth/oauth-accounts/ — see
5
+ * apps/authentication/api/oauth_accounts_view.py.
6
+ */
7
+
8
+ import { request } from './_fetch';
9
+ import type { ConnectedAccount, PaginatedResponse } from '../types/index';
10
+
11
+ export async function listConnectedAccounts(): Promise<ConnectedAccount[]> {
12
+ const resp = await request<PaginatedResponse<ConnectedAccount>>(
13
+ '/auth/oauth-accounts/',
14
+ );
15
+ return resp.results;
16
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Calendar API client — list events scoped to the requesting user's OAuthAccounts.
3
+ */
4
+
5
+ import { request } from './_fetch';
6
+ import type {
7
+ CalendarEvent,
8
+ CalendarWindow,
9
+ OAuthProvider,
10
+ PaginatedResponse,
11
+ } from '../types/index';
12
+
13
+ function _toIso(d: Date | string): string {
14
+ return typeof d === 'string' ? d : d.toISOString();
15
+ }
16
+
17
+ export interface ListCalendarEventsOptions extends CalendarWindow {
18
+ provider?: OAuthProvider;
19
+ calendar_id?: string;
20
+ page?: number;
21
+ page_size?: number;
22
+ }
23
+
24
+ export async function listCalendarEvents(
25
+ opts: ListCalendarEventsOptions,
26
+ ): Promise<PaginatedResponse<CalendarEvent>> {
27
+ return request<PaginatedResponse<CalendarEvent>>('/calendar/events/', {
28
+ query: {
29
+ from: _toIso(opts.from),
30
+ to: _toIso(opts.to),
31
+ provider: opts.provider,
32
+ calendar_id: opts.calendar_id,
33
+ page: opts.page,
34
+ page_size: opts.page_size,
35
+ },
36
+ });
37
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Email API client — list messages scoped to the requesting user's OAuthAccounts.
3
+ */
4
+
5
+ import { request } from './_fetch';
6
+ import type {
7
+ EmailMessage,
8
+ OAuthProvider,
9
+ PaginatedResponse,
10
+ } from '../types/index';
11
+
12
+ export interface ListEmailMessagesOptions {
13
+ provider?: OAuthProvider;
14
+ account_id?: string;
15
+ since?: Date | string;
16
+ is_outbound?: boolean;
17
+ page?: number;
18
+ page_size?: number;
19
+ }
20
+
21
+ function _toIso(d: Date | string | undefined): string | undefined {
22
+ if (d === undefined) return undefined;
23
+ return typeof d === 'string' ? d : d.toISOString();
24
+ }
25
+
26
+ export async function listEmailMessages(
27
+ opts: ListEmailMessagesOptions = {},
28
+ ): Promise<PaginatedResponse<EmailMessage>> {
29
+ return request<PaginatedResponse<EmailMessage>>('/email/messages/', {
30
+ query: {
31
+ provider: opts.provider,
32
+ account_id: opts.account_id,
33
+ since: _toIso(opts.since),
34
+ is_outbound:
35
+ opts.is_outbound === undefined ? undefined : String(opts.is_outbound),
36
+ page: opts.page,
37
+ page_size: opts.page_size,
38
+ },
39
+ });
40
+ }
@@ -0,0 +1,5 @@
1
+ export * from './oauth';
2
+ export * from './calendar';
3
+ export * from './email';
4
+ export * from './accounts';
5
+ export { setApiBase, getApiBase, setAuthTokenProvider } from './_fetch';
@@ -0,0 +1,34 @@
1
+ /**
2
+ * OAuth flow API client — connect / callback / revoke.
3
+ */
4
+
5
+ import { request } from './_fetch';
6
+ import type { OAuthProvider } from '../types/index';
7
+
8
+ export interface InitiateResponse {
9
+ authorization_url: string;
10
+ }
11
+
12
+ export async function connectProvider(
13
+ provider: OAuthProvider,
14
+ redirectUri?: string,
15
+ ): Promise<InitiateResponse> {
16
+ return request<InitiateResponse>(`/auth/oauth/${provider}/initiate/`, {
17
+ method: 'POST',
18
+ body: redirectUri ? { redirect_uri: redirectUri } : {},
19
+ });
20
+ }
21
+
22
+ export interface RevokeResponse {
23
+ status: 'revoked' | string;
24
+ }
25
+
26
+ export async function revokeAccount(
27
+ provider: OAuthProvider,
28
+ oauthAccountId: string,
29
+ ): Promise<RevokeResponse> {
30
+ return request<RevokeResponse>(`/auth/oauth/${provider}/revoke/`, {
31
+ method: 'POST',
32
+ body: { oauth_account_id: oauthAccountId },
33
+ });
34
+ }