@devosurf/tesser-connectors 0.1.0-alpha.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.
@@ -0,0 +1,250 @@
1
+ // GitHub connector — P0 exemplar: named auth map (OAuth + PAT), auto-registered webhook
2
+ // trigger, poll trigger, derived retry-safety. Actions return OUR mapped stable shapes.
3
+
4
+ import { z } from "zod";
5
+ import {
6
+ action,
7
+ apiKey,
8
+ defineConnector,
9
+ oauth2,
10
+ trigger,
11
+ verify,
12
+ } from "@devosurf/tesser-sdk/connector";
13
+ import { githubProvider } from "../providers/github.js";
14
+
15
+ const issueShape = z.object({
16
+ repo: z.string(),
17
+ number: z.number(),
18
+ title: z.string(),
19
+ url: z.string(),
20
+ state: z.string(),
21
+ author: z.string(),
22
+ body: z.string().nullable(),
23
+ labels: z.array(z.string()),
24
+ createdAt: z.string(),
25
+ });
26
+
27
+ type RawIssue = {
28
+ number: number;
29
+ title: string;
30
+ html_url: string;
31
+ state: string;
32
+ body: string | null;
33
+ user?: { login?: string };
34
+ labels?: Array<{ name?: string } | string>;
35
+ created_at: string;
36
+ repository?: { full_name?: string };
37
+ pull_request?: unknown;
38
+ };
39
+
40
+ function mapIssue(raw: RawIssue, repo: string): z.infer<typeof issueShape> {
41
+ return {
42
+ repo: raw.repository?.full_name ?? repo,
43
+ number: raw.number,
44
+ title: raw.title,
45
+ url: raw.html_url,
46
+ state: raw.state,
47
+ author: raw.user?.login ?? "unknown",
48
+ body: raw.body ?? null,
49
+ labels: (raw.labels ?? []).map((l) => (typeof l === "string" ? l : (l.name ?? ""))).filter(Boolean),
50
+ createdAt: raw.created_at,
51
+ };
52
+ }
53
+
54
+ export default defineConnector({
55
+ id: "github",
56
+ describe: "GitHub repositories, issues, and webhooks",
57
+ provider: githubProvider,
58
+ auth: {
59
+ oauth: oauth2({ provider: "github", scopes: ["repo"] }),
60
+ token: apiKey({ prefix: "Bearer ", describe: "Personal access token (repo scope)" }),
61
+ },
62
+ defaultHeaders: {
63
+ accept: "application/vnd.github+json",
64
+ "x-github-api-version": "2022-11-28",
65
+ "user-agent": "tesser",
66
+ },
67
+ samples: {
68
+ "issues.list": [
69
+ {
70
+ repo: "acme/api",
71
+ number: 41,
72
+ title: "crash on save",
73
+ url: "https://github.com/acme/api/issues/41",
74
+ state: "open",
75
+ author: "octocat",
76
+ body: "It crashes.",
77
+ labels: ["bug"],
78
+ createdAt: "2026-01-01T00:00:00Z",
79
+ },
80
+ ],
81
+ "issues.create": {
82
+ repo: "acme/api",
83
+ number: 42,
84
+ title: "sample",
85
+ url: "https://github.com/acme/api/issues/42",
86
+ state: "open",
87
+ author: "octocat",
88
+ body: null,
89
+ labels: [],
90
+ createdAt: "2026-01-01T00:00:00Z",
91
+ },
92
+ "issues.comment": { id: 1, url: "https://github.com/acme/api/issues/42#issuecomment-1" },
93
+ "repos.get": { fullName: "acme/api", defaultBranch: "main", private: true, description: null },
94
+ "trigger:issueOpened": {
95
+ repo: "acme/api",
96
+ number: 43,
97
+ title: "new bug",
98
+ url: "https://github.com/acme/api/issues/43",
99
+ state: "open",
100
+ author: "octocat",
101
+ body: null,
102
+ labels: ["bug"],
103
+ createdAt: "2026-01-01T00:00:00Z",
104
+ },
105
+ },
106
+ actions: {
107
+ issues: {
108
+ list: action({
109
+ describe: "List issues — across your repos, or one repo when `repo` is set",
110
+ input: z.object({
111
+ repo: z.string().optional(),
112
+ state: z.enum(["open", "closed", "all"]).default("open"),
113
+ labels: z.array(z.string()).optional(),
114
+ limit: z.number().int().min(1).max(100).default(30),
115
+ }),
116
+ output: z.array(issueShape),
117
+ safety: "read",
118
+ run: async (ctx, i) => {
119
+ const query: Record<string, string | number> = { state: i.state, per_page: i.limit };
120
+ if (i.labels && i.labels.length > 0) query["labels"] = i.labels.join(",");
121
+ const path = i.repo ? `/repos/${i.repo}/issues` : "/issues";
122
+ if (!i.repo) query["filter"] = "all";
123
+ const raw = (await ctx.http.get(path, { query })) as RawIssue[];
124
+ // The issues API interleaves PRs; an issues connector returns issues.
125
+ return raw.filter((r) => r.pull_request === undefined).map((r) => mapIssue(r, i.repo ?? ""));
126
+ },
127
+ }),
128
+ create: action({
129
+ describe: "Open an issue",
130
+ input: z.object({
131
+ repo: z.string(),
132
+ title: z.string().min(1),
133
+ body: z.string().optional(),
134
+ labels: z.array(z.string()).optional(),
135
+ }),
136
+ output: issueShape,
137
+ run: async (ctx, i) => {
138
+ const raw = (await ctx.http.post(`/repos/${i.repo}/issues`, {
139
+ title: i.title,
140
+ ...(i.body !== undefined ? { body: i.body } : {}),
141
+ ...(i.labels !== undefined ? { labels: i.labels } : {}),
142
+ })) as RawIssue;
143
+ return mapIssue(raw, i.repo);
144
+ },
145
+ }),
146
+ comment: action({
147
+ describe: "Comment on an issue",
148
+ input: z.object({ repo: z.string(), number: z.number().int(), body: z.string().min(1) }),
149
+ output: z.object({ id: z.number(), url: z.string() }),
150
+ run: async (ctx, i) => {
151
+ const raw = (await ctx.http.post(`/repos/${i.repo}/issues/${i.number}/comments`, {
152
+ body: i.body,
153
+ })) as { id: number; html_url: string };
154
+ return { id: raw.id, url: raw.html_url };
155
+ },
156
+ }),
157
+ },
158
+ repos: {
159
+ get: action({
160
+ describe: "Fetch one repository",
161
+ input: z.object({ repo: z.string() }),
162
+ output: z.object({
163
+ fullName: z.string(),
164
+ defaultBranch: z.string(),
165
+ private: z.boolean(),
166
+ description: z.string().nullable(),
167
+ }),
168
+ safety: "read",
169
+ run: async (ctx, i) => {
170
+ const raw = (await ctx.http.get(`/repos/${i.repo}`)) as {
171
+ full_name: string;
172
+ default_branch: string;
173
+ private: boolean;
174
+ description: string | null;
175
+ };
176
+ return {
177
+ fullName: raw.full_name,
178
+ defaultBranch: raw.default_branch,
179
+ private: raw.private,
180
+ description: raw.description ?? null,
181
+ };
182
+ },
183
+ }),
184
+ },
185
+ },
186
+ webhook: {
187
+ verify: verify.hmacSha256({ header: "X-Hub-Signature-256", prefix: "sha256=" }),
188
+ identify: (req) => {
189
+ const event = req.headers["x-github-event"];
190
+ if (!event) return null;
191
+ return {
192
+ event,
193
+ ...(req.headers["x-github-delivery"] !== undefined
194
+ ? { deliveryId: req.headers["x-github-delivery"] }
195
+ : {}),
196
+ payload: req.json,
197
+ };
198
+ },
199
+ },
200
+ triggers: {
201
+ issueOpened: trigger.webhook({
202
+ describe: "Fires when an issue is opened",
203
+ input: z.object({ repo: z.string() }),
204
+ output: issueShape,
205
+ event: "issues",
206
+ map: (payload, params) => {
207
+ const p = payload as { action?: string; issue?: RawIssue; repository?: { full_name?: string } };
208
+ if (p.action !== "opened" || !p.issue) return null;
209
+ const repo = p.repository?.full_name ?? params.repo;
210
+ if (repo !== params.repo) return null;
211
+ return mapIssue(p.issue, repo);
212
+ },
213
+ register: {
214
+ mode: "auto",
215
+ create: async (ctx, reg, params) => {
216
+ const res = (await ctx.http.post(`/repos/${params.repo}/hooks`, {
217
+ name: "web",
218
+ active: true,
219
+ events: ["issues"],
220
+ config: { url: reg.url, content_type: "json", secret: reg.secret },
221
+ })) as { id: number };
222
+ return { externalId: String(res.id) };
223
+ },
224
+ destroy: async (ctx, reg, params) => {
225
+ if (reg.externalId === undefined) return;
226
+ await ctx.http.delete(`/repos/${params.repo}/hooks/${reg.externalId}`);
227
+ },
228
+ },
229
+ }),
230
+ newIssue: trigger.poll({
231
+ describe: "Fires for each newly created issue (poll)",
232
+ input: z.object({ repo: z.string(), labels: z.array(z.string()).optional() }),
233
+ output: issueShape,
234
+ interval: { default: "2m", floor: "30s" },
235
+ poll: async (ctx, params) => {
236
+ const query: Record<string, string | number> = {
237
+ state: "all",
238
+ sort: "created",
239
+ direction: "desc",
240
+ per_page: 30,
241
+ };
242
+ if (params.labels && params.labels.length > 0) query["labels"] = params.labels.join(",");
243
+ const raw = (await ctx.http.get(`/repos/${params.repo}/issues`, { query })) as RawIssue[];
244
+ return raw.filter((r) => r.pull_request === undefined);
245
+ },
246
+ dedupeKey: (item) => String((item as RawIssue).number),
247
+ map: (item, params) => mapIssue(item as RawIssue, params.repo),
248
+ }),
249
+ },
250
+ });
package/gmail/index.ts ADDED
@@ -0,0 +1,420 @@
1
+ // Gmail connector — sits behind the shared `google` Provider (one OAuth app backs
2
+ // gmail + google-calendar; CONTEXT.md "Provider"). Demonstrates Google's offline-access
3
+ // refresh quirks via the provider facts.
4
+
5
+ import { z } from "zod";
6
+ import { action, defineConnector, oauth2, trigger, type ActionCtx } from "@devosurf/tesser-sdk/connector";
7
+ import { googleProvider } from "../providers/google.js";
8
+
9
+ function toBase64Url(s: string): string {
10
+ return Buffer.from(s).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
11
+ }
12
+
13
+ // RFC 2822 MIME builder → base64url, the shape Gmail's `raw` field wants. text/plain
14
+ // unless `html` is set; threading carries In-Reply-To = the original Message-ID.
15
+ function buildMime(opts: {
16
+ to: string;
17
+ subject: string;
18
+ body: string;
19
+ cc?: string;
20
+ html?: boolean;
21
+ inReplyTo?: string;
22
+ }): string {
23
+ const lines = [`To: ${opts.to}`];
24
+ if (opts.cc !== undefined) lines.push(`Cc: ${opts.cc}`);
25
+ lines.push(`Subject: ${opts.subject}`);
26
+ if (opts.inReplyTo !== undefined) {
27
+ // In-Reply-To AND References both point at the original Message-ID (client threading).
28
+ lines.push(`In-Reply-To: ${opts.inReplyTo}`, `References: ${opts.inReplyTo}`);
29
+ }
30
+ lines.push(
31
+ `Content-Type: ${opts.html ? "text/html" : "text/plain"}; charset=utf-8`,
32
+ "",
33
+ opts.body,
34
+ );
35
+ return toBase64Url(lines.join("\r\n"));
36
+ }
37
+
38
+ const messageDetail = z.object({
39
+ id: z.string(),
40
+ threadId: z.string(),
41
+ from: z.string(),
42
+ subject: z.string(),
43
+ snippet: z.string(),
44
+ receivedAt: z.string(),
45
+ });
46
+
47
+ const labelShape = z.object({
48
+ id: z.string(),
49
+ name: z.string(),
50
+ type: z.string(),
51
+ });
52
+
53
+ type RawMessage = {
54
+ id: string;
55
+ threadId: string;
56
+ snippet?: string;
57
+ internalDate?: string;
58
+ payload?: { headers?: Array<{ name: string; value: string }> };
59
+ };
60
+
61
+ type RawLabel = { id: string; name: string; type?: string };
62
+
63
+ function header(raw: RawMessage, name: string): string {
64
+ return raw.payload?.headers?.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value ?? "";
65
+ }
66
+
67
+ function mapMessage(raw: RawMessage): z.infer<typeof messageDetail> {
68
+ return {
69
+ id: raw.id,
70
+ threadId: raw.threadId,
71
+ from: header(raw, "From"),
72
+ subject: header(raw, "Subject"),
73
+ snippet: raw.snippet ?? "",
74
+ receivedAt: raw.internalDate
75
+ ? new Date(Number(raw.internalDate)).toISOString()
76
+ : new Date(0).toISOString(),
77
+ };
78
+ }
79
+
80
+ function mapLabel(raw: RawLabel): z.infer<typeof labelShape> {
81
+ return { id: raw.id, name: raw.name, type: raw.type ?? "user" };
82
+ }
83
+
84
+ async function fetchMessage(ctx: ActionCtx, id: string): Promise<RawMessage> {
85
+ // format=metadata returns headers + snippet without the body payload.
86
+ return (await ctx.http.get(`/users/me/messages/${id}`, {
87
+ query: { format: "metadata" },
88
+ })) as RawMessage;
89
+ }
90
+
91
+ // Shared body for the modify family. Idempotent (re-applying the same label set is a no-op),
92
+ // so callers set retrySafe:true.
93
+ async function modifyMessage(
94
+ ctx: ActionCtx,
95
+ id: string,
96
+ change: { addLabelIds?: string[]; removeLabelIds?: string[] },
97
+ ): Promise<z.infer<typeof messageDetail>> {
98
+ const res = (await ctx.http.post(`/users/me/messages/${id}/modify`, {
99
+ addLabelIds: change.addLabelIds ?? [],
100
+ removeLabelIds: change.removeLabelIds ?? [],
101
+ })) as RawMessage;
102
+ // modify echoes the message metadata; map it through our stable shape.
103
+ return mapMessage(res);
104
+ }
105
+
106
+ export default defineConnector({
107
+ id: "gmail",
108
+ describe: "Gmail — read, send, label, draft, and organize mail as the connected account",
109
+ provider: googleProvider,
110
+ baseUrl: "https://gmail.googleapis.com/gmail/v1",
111
+ auth: oauth2({
112
+ provider: "google",
113
+ scopes: [
114
+ "https://www.googleapis.com/auth/gmail.readonly",
115
+ "https://www.googleapis.com/auth/gmail.send",
116
+ "https://www.googleapis.com/auth/gmail.modify",
117
+ "https://www.googleapis.com/auth/gmail.labels",
118
+ ],
119
+ }),
120
+ samples: {
121
+ "messages.list": [{ id: "18c2f0a", threadId: "18c2f0a", snippet: "sample message" }],
122
+ "messages.send": { id: "18c2f0b", threadId: "18c2f0b" },
123
+ "messages.get": {
124
+ id: "18c2f0a",
125
+ threadId: "18c2f0a",
126
+ from: "Ada <ada@example.com>",
127
+ subject: "sample subject",
128
+ snippet: "sample message",
129
+ receivedAt: "2026-01-01T00:00:00.000Z",
130
+ },
131
+ "messages.modify": {
132
+ id: "18c2f0a",
133
+ threadId: "18c2f0a",
134
+ from: "Ada <ada@example.com>",
135
+ subject: "sample subject",
136
+ snippet: "sample message",
137
+ receivedAt: "2026-01-01T00:00:00.000Z",
138
+ },
139
+ "messages.markRead": {
140
+ id: "18c2f0a",
141
+ threadId: "18c2f0a",
142
+ from: "Ada <ada@example.com>",
143
+ subject: "sample subject",
144
+ snippet: "sample message",
145
+ receivedAt: "2026-01-01T00:00:00.000Z",
146
+ },
147
+ "messages.archive": {
148
+ id: "18c2f0a",
149
+ threadId: "18c2f0a",
150
+ from: "Ada <ada@example.com>",
151
+ subject: "sample subject",
152
+ snippet: "sample message",
153
+ receivedAt: "2026-01-01T00:00:00.000Z",
154
+ },
155
+ "messages.trash": { id: "18c2f0a", threadId: "18c2f0a" },
156
+ "messages.attachments.get": { attachmentId: "ANGjdJ8", size: 1234, data: "aGVsbG8" },
157
+ "labels.list": [
158
+ { id: "INBOX", name: "INBOX", type: "system" },
159
+ { id: "Label_12", name: "Receipts", type: "user" },
160
+ ],
161
+ "labels.create": { id: "Label_42", name: "Receipts", type: "user" },
162
+ "drafts.create": { id: "r-123", messageId: "18c2f0d", threadId: "18c2f0d" },
163
+ "drafts.send": { id: "18c2f0e", threadId: "18c2f0e" },
164
+ "threads.get": {
165
+ id: "18c2f0a",
166
+ historyId: "99",
167
+ messages: [
168
+ {
169
+ id: "18c2f0a",
170
+ threadId: "18c2f0a",
171
+ from: "Ada <ada@example.com>",
172
+ subject: "sample subject",
173
+ snippet: "sample message",
174
+ receivedAt: "2026-01-01T00:00:00.000Z",
175
+ },
176
+ ],
177
+ },
178
+ "trigger:messageReceived": {
179
+ id: "18c2f0c",
180
+ threadId: "18c2f0c",
181
+ from: "Ada <ada@example.com>",
182
+ subject: "new mail",
183
+ snippet: "hello there",
184
+ receivedAt: "2026-01-01T00:00:00.000Z",
185
+ },
186
+ },
187
+ actions: {
188
+ messages: {
189
+ list: action({
190
+ describe: "List message ids matching a Gmail query",
191
+ input: z.object({
192
+ q: z.string().optional(),
193
+ maxResults: z.number().int().min(1).max(100).default(10),
194
+ }),
195
+ output: z.array(z.object({ id: z.string(), threadId: z.string(), snippet: z.string() })),
196
+ safety: "read",
197
+ run: async (ctx, i) => {
198
+ const res = (await ctx.http.get("/users/me/messages", {
199
+ query: { maxResults: i.maxResults, ...(i.q !== undefined ? { q: i.q } : {}) },
200
+ })) as { messages?: Array<{ id: string; threadId: string }> };
201
+ const out = [] as Array<{ id: string; threadId: string; snippet: string }>;
202
+ for (const m of res.messages ?? []) {
203
+ const full = (await ctx.http.get(`/users/me/messages/${m.id}`, {
204
+ query: { format: "metadata" },
205
+ })) as { snippet?: string };
206
+ out.push({ id: m.id, threadId: m.threadId, snippet: full.snippet ?? "" });
207
+ }
208
+ return out;
209
+ },
210
+ }),
211
+ send: action({
212
+ describe: "Send an email (plain text or HTML; optional cc and reply threading)",
213
+ input: z.object({
214
+ to: z.string().min(3),
215
+ subject: z.string(),
216
+ text: z.string(),
217
+ html: z.boolean().optional(),
218
+ cc: z.string().optional(),
219
+ // Reply into an existing conversation: threadId AND the original Message-ID.
220
+ threadId: z.string().optional(),
221
+ inReplyTo: z.string().optional(),
222
+ }),
223
+ output: z.object({ id: z.string(), threadId: z.string() }),
224
+ run: async (ctx, i) => {
225
+ const raw = buildMime({
226
+ to: i.to,
227
+ subject: i.subject,
228
+ body: i.text,
229
+ ...(i.cc !== undefined ? { cc: i.cc } : {}),
230
+ ...(i.html !== undefined ? { html: i.html } : {}),
231
+ ...(i.inReplyTo !== undefined ? { inReplyTo: i.inReplyTo } : {}),
232
+ });
233
+ const res = (await ctx.http.post("/users/me/messages/send", {
234
+ raw,
235
+ ...(i.threadId !== undefined ? { threadId: i.threadId } : {}),
236
+ })) as { id: string; threadId: string };
237
+ return { id: res.id, threadId: res.threadId };
238
+ },
239
+ }),
240
+ get: action({
241
+ describe: "Fetch one message's headers + snippet",
242
+ input: z.object({ id: z.string().min(1) }),
243
+ output: messageDetail,
244
+ safety: "read",
245
+ run: async (ctx, i) => mapMessage(await fetchMessage(ctx, i.id)),
246
+ }),
247
+ modify: action({
248
+ describe: "Add and/or remove labels on a message",
249
+ input: z.object({
250
+ id: z.string().min(1),
251
+ addLabelIds: z.array(z.string()).optional(),
252
+ removeLabelIds: z.array(z.string()).optional(),
253
+ }),
254
+ output: messageDetail,
255
+ // Re-applying the same label set is a no-op → safe to retry transient 5xx.
256
+ retrySafe: true,
257
+ run: async (ctx, i) =>
258
+ modifyMessage(ctx, i.id, {
259
+ ...(i.addLabelIds !== undefined ? { addLabelIds: i.addLabelIds } : {}),
260
+ ...(i.removeLabelIds !== undefined ? { removeLabelIds: i.removeLabelIds } : {}),
261
+ }),
262
+ }),
263
+ markRead: action({
264
+ describe: "Mark a message read (removes the UNREAD label)",
265
+ input: z.object({ id: z.string().min(1) }),
266
+ output: messageDetail,
267
+ retrySafe: true,
268
+ run: async (ctx, i) => modifyMessage(ctx, i.id, { removeLabelIds: ["UNREAD"] }),
269
+ }),
270
+ archive: action({
271
+ describe: "Archive a message (removes the INBOX label)",
272
+ input: z.object({ id: z.string().min(1) }),
273
+ output: messageDetail,
274
+ retrySafe: true,
275
+ run: async (ctx, i) => modifyMessage(ctx, i.id, { removeLabelIds: ["INBOX"] }),
276
+ }),
277
+ trash: action({
278
+ describe: "Move a message to the trash",
279
+ input: z.object({ id: z.string().min(1) }),
280
+ output: z.object({ id: z.string(), threadId: z.string() }),
281
+ // Trashing an already-trashed message is a no-op → retry-safe.
282
+ retrySafe: true,
283
+ run: async (ctx, i) => {
284
+ const res = (await ctx.http.post(`/users/me/messages/${i.id}/trash`, {})) as {
285
+ id: string;
286
+ threadId: string;
287
+ };
288
+ return { id: res.id, threadId: res.threadId };
289
+ },
290
+ }),
291
+ attachments: {
292
+ get: action({
293
+ describe: "Download one message attachment (base64url data)",
294
+ input: z.object({ messageId: z.string().min(1), id: z.string().min(1) }),
295
+ output: z.object({ attachmentId: z.string(), size: z.number(), data: z.string() }),
296
+ safety: "read",
297
+ run: async (ctx, i) => {
298
+ const res = (await ctx.http.get(
299
+ `/users/me/messages/${i.messageId}/attachments/${i.id}`,
300
+ )) as { attachmentId?: string; size?: number; data?: string };
301
+ return { attachmentId: res.attachmentId ?? i.id, size: res.size ?? 0, data: res.data ?? "" };
302
+ },
303
+ }),
304
+ },
305
+ },
306
+ labels: {
307
+ list: action({
308
+ describe: "List all labels (resolve a label name to its id)",
309
+ input: z.object({}),
310
+ output: z.array(labelShape),
311
+ safety: "read",
312
+ run: async (ctx) => {
313
+ const res = (await ctx.http.get("/users/me/labels")) as { labels?: RawLabel[] };
314
+ return (res.labels ?? []).map(mapLabel);
315
+ },
316
+ }),
317
+ create: action({
318
+ describe: "Create a user label",
319
+ input: z.object({
320
+ name: z.string().min(1),
321
+ labelListVisibility: z.enum(["labelShow", "labelShowIfUnread", "labelHide"]).optional(),
322
+ messageListVisibility: z.enum(["show", "hide"]).optional(),
323
+ }),
324
+ output: labelShape,
325
+ // 409 on a duplicate name (terminal); creation is NOT idempotent → no retrySafe.
326
+ run: async (ctx, i) => {
327
+ const res = (await ctx.http.post("/users/me/labels", {
328
+ name: i.name,
329
+ ...(i.labelListVisibility !== undefined ? { labelListVisibility: i.labelListVisibility } : {}),
330
+ ...(i.messageListVisibility !== undefined ? { messageListVisibility: i.messageListVisibility } : {}),
331
+ })) as RawLabel;
332
+ return mapLabel(res);
333
+ },
334
+ }),
335
+ },
336
+ drafts: {
337
+ create: action({
338
+ describe: "Create a draft from a plain-text or HTML message",
339
+ input: z.object({
340
+ to: z.string().min(3),
341
+ subject: z.string(),
342
+ text: z.string(),
343
+ html: z.boolean().optional(),
344
+ cc: z.string().optional(),
345
+ threadId: z.string().optional(),
346
+ }),
347
+ output: z.object({ id: z.string(), messageId: z.string(), threadId: z.string() }),
348
+ run: async (ctx, i) => {
349
+ const raw = buildMime({
350
+ to: i.to,
351
+ subject: i.subject,
352
+ body: i.text,
353
+ ...(i.cc !== undefined ? { cc: i.cc } : {}),
354
+ ...(i.html !== undefined ? { html: i.html } : {}),
355
+ });
356
+ const res = (await ctx.http.post("/users/me/drafts", {
357
+ message: { raw, ...(i.threadId !== undefined ? { threadId: i.threadId } : {}) },
358
+ })) as { id: string; message?: { id: string; threadId: string } };
359
+ return { id: res.id, messageId: res.message?.id ?? "", threadId: res.message?.threadId ?? "" };
360
+ },
361
+ }),
362
+ send: action({
363
+ describe: "Send an existing draft (the draft id goes in the body, not the path)",
364
+ input: z.object({ id: z.string().min(1) }),
365
+ output: z.object({ id: z.string(), threadId: z.string() }),
366
+ // POST /users/me/drafts/send with { id } in the body — NO {id} path segment.
367
+ // Sending is NOT idempotent (double-send) → no retrySafe.
368
+ run: async (ctx, i) => {
369
+ const res = (await ctx.http.post("/users/me/drafts/send", { id: i.id })) as {
370
+ id: string;
371
+ threadId: string;
372
+ };
373
+ return { id: res.id, threadId: res.threadId };
374
+ },
375
+ }),
376
+ },
377
+ threads: {
378
+ get: action({
379
+ describe: "Fetch a whole conversation (all messages in one call)",
380
+ input: z.object({ id: z.string().min(1) }),
381
+ output: z.object({
382
+ id: z.string(),
383
+ historyId: z.string(),
384
+ messages: z.array(messageDetail),
385
+ }),
386
+ safety: "read",
387
+ run: async (ctx, i) => {
388
+ const res = (await ctx.http.get(`/users/me/threads/${i.id}`, {
389
+ query: { format: "metadata" },
390
+ })) as { id: string; historyId?: string; messages?: RawMessage[] };
391
+ return {
392
+ id: res.id,
393
+ historyId: res.historyId ?? "",
394
+ messages: (res.messages ?? []).map(mapMessage),
395
+ };
396
+ },
397
+ }),
398
+ },
399
+ },
400
+ triggers: {
401
+ messageReceived: trigger.poll({
402
+ describe: "Fires for each new email in the connected inbox (optionally matching a Gmail query)",
403
+ input: z.object({ q: z.string().optional() }),
404
+ output: messageDetail,
405
+ interval: { default: "1m", floor: "30s" },
406
+ order: "newest-first",
407
+ poll: async (ctx, params) => {
408
+ const list = (await ctx.http.get("/users/me/messages", {
409
+ query: { maxResults: 15, q: params.q ?? "in:inbox" },
410
+ })) as { messages?: Array<{ id: string }> };
411
+ // Enrich here (map() has no ctx by design): 15 metadata gets per poll, worst case.
412
+ const out: RawMessage[] = [];
413
+ for (const m of list.messages ?? []) out.push(await fetchMessage(ctx, m.id));
414
+ return out;
415
+ },
416
+ dedupeKey: (raw) => raw.id,
417
+ map: (raw) => mapMessage(raw),
418
+ }),
419
+ },
420
+ });