runline 0.2.2 → 0.3.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,1075 @@
1
+ /**
2
+ * Gmail plugin for runline.
3
+ *
4
+ * Authentication: OAuth2 user flow, seeded once via
5
+ * `runline auth gmail`. The connection stores `clientId`,
6
+ * `clientSecret`, `refreshToken`, plus cached `accessToken` +
7
+ * `accessTokenExpiresAt`. When the cached token is missing or
8
+ * within 60 s of expiry, the plugin refreshes against
9
+ * `https://oauth2.googleapis.com/token` and persists the new
10
+ * token via `ctx.updateConnection`. File-level locking inside
11
+ * runline core keeps concurrent refreshes safe.
12
+ *
13
+ * This plugin deliberately doesn't depend on nodemailer or
14
+ * mailparser. MIME building is a minimal hand-rolled encoder
15
+ * sufficient for Gmail's `users.messages.send` (`raw` field): a
16
+ * `multipart/mixed` root when attachments are present, with
17
+ * `multipart/alternative` for text+html bodies, or a flat
18
+ * `text/plain` / `text/html` body otherwise. Parsing incoming
19
+ * `raw` messages is left to the caller — actions return the
20
+ * raw Gmail API response (including base64url `raw` for format=raw,
21
+ * or parsed `payload` tree for format=full).
22
+ */
23
+ // ─── OAuth ───────────────────────────────────────────────────────
24
+ const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
25
+ const REFRESH_SKEW_MS = 60_000;
26
+ async function refreshAccessToken(ctx) {
27
+ const cfg = ctx.connection.config;
28
+ const { clientId, clientSecret, refreshToken } = cfg;
29
+ if (!clientId || !clientSecret || !refreshToken) {
30
+ throw new Error("gmail: missing clientId/clientSecret/refreshToken. Run the Gmail OAuth helper to seed these.");
31
+ }
32
+ const body = new URLSearchParams({
33
+ client_id: clientId,
34
+ client_secret: clientSecret,
35
+ refresh_token: refreshToken,
36
+ grant_type: "refresh_token",
37
+ });
38
+ const res = await fetch(TOKEN_ENDPOINT, {
39
+ method: "POST",
40
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
41
+ body: body.toString(),
42
+ });
43
+ if (!res.ok) {
44
+ const text = await res.text();
45
+ throw new Error(`gmail: token refresh failed (${res.status}): ${text}`);
46
+ }
47
+ const data = (await res.json());
48
+ const expiresAt = Date.now() + data.expires_in * 1000;
49
+ await ctx.updateConnection({
50
+ accessToken: data.access_token,
51
+ accessTokenExpiresAt: expiresAt,
52
+ });
53
+ return data.access_token;
54
+ }
55
+ async function accessToken(ctx) {
56
+ const cfg = ctx.connection.config;
57
+ if (cfg.accessToken &&
58
+ typeof cfg.accessTokenExpiresAt === "number" &&
59
+ Date.now() < cfg.accessTokenExpiresAt - REFRESH_SKEW_MS) {
60
+ return cfg.accessToken;
61
+ }
62
+ return refreshAccessToken(ctx);
63
+ }
64
+ // ─── Request ─────────────────────────────────────────────────────
65
+ const API_BASE = "https://gmail.googleapis.com/gmail/v1/users/me";
66
+ async function gmailRequest(ctx, method, path, body, qs) {
67
+ const token = await accessToken(ctx);
68
+ const url = new URL(`${API_BASE}${path}`);
69
+ if (qs) {
70
+ for (const [k, v] of Object.entries(qs)) {
71
+ if (v === undefined || v === null)
72
+ continue;
73
+ if (Array.isArray(v)) {
74
+ for (const entry of v)
75
+ url.searchParams.append(k, String(entry));
76
+ }
77
+ else {
78
+ url.searchParams.set(k, String(v));
79
+ }
80
+ }
81
+ }
82
+ const init = {
83
+ method,
84
+ headers: {
85
+ Authorization: `Bearer ${token}`,
86
+ Accept: "application/json",
87
+ },
88
+ };
89
+ if (body && Object.keys(body).length > 0) {
90
+ init.headers["Content-Type"] =
91
+ "application/json";
92
+ init.body = JSON.stringify(body);
93
+ }
94
+ const res = await fetch(url.toString(), init);
95
+ if (res.status === 204)
96
+ return { success: true };
97
+ const text = await res.text();
98
+ if (!res.ok) {
99
+ throw new Error(`gmail: ${method} ${path} → ${res.status} ${text}`);
100
+ }
101
+ return text ? JSON.parse(text) : { success: true };
102
+ }
103
+ async function paginateAll(ctx, path, key, qs) {
104
+ const out = [];
105
+ const query = { ...qs, maxResults: 100 };
106
+ do {
107
+ const page = (await gmailRequest(ctx, "GET", path, undefined, query));
108
+ const items = page[key] ?? [];
109
+ out.push(...items);
110
+ query.pageToken = page.nextPageToken;
111
+ } while (query.pageToken);
112
+ return out;
113
+ }
114
+ // ─── MIME encoding ───────────────────────────────────────────────
115
+ const CRLF = "\r\n";
116
+ function base64url(bytes) {
117
+ const buf = typeof bytes === "string" ? Buffer.from(bytes, "utf-8") : Buffer.from(bytes);
118
+ return buf
119
+ .toString("base64")
120
+ .replace(/\+/g, "-")
121
+ .replace(/\//g, "_")
122
+ .replace(/=+$/, "");
123
+ }
124
+ function randomBoundary() {
125
+ return `----=_runline_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
126
+ }
127
+ function needsEncoding(s) {
128
+ // Anything outside printable ASCII gets MIME-encoded-word treatment.
129
+ // Covers non-ASCII subjects/names and a handful of structural chars.
130
+ // eslint-disable-next-line no-control-regex
131
+ return /[^\x20-\x7e]/.test(s);
132
+ }
133
+ function encodeHeaderWord(s) {
134
+ if (!needsEncoding(s))
135
+ return s;
136
+ return `=?UTF-8?B?${Buffer.from(s, "utf-8").toString("base64")}?=`;
137
+ }
138
+ function header(name, value) {
139
+ if (!value)
140
+ return "";
141
+ return `${name}: ${value}${CRLF}`;
142
+ }
143
+ function textPart(body, mimeType) {
144
+ const encoded = Buffer.from(body, "utf-8").toString("base64");
145
+ // Fold base64 to 76 chars per RFC 2045.
146
+ const folded = encoded.match(/.{1,76}/g)?.join(CRLF) ?? encoded;
147
+ return (`Content-Type: ${mimeType}; charset="UTF-8"${CRLF}` +
148
+ `Content-Transfer-Encoding: base64${CRLF}${CRLF}` +
149
+ `${folded}${CRLF}`);
150
+ }
151
+ function attachmentPart(att) {
152
+ const encodedName = encodeHeaderWord(att.name);
153
+ const folded = att.contentBase64.match(/.{1,76}/g)?.join(CRLF) ?? att.contentBase64;
154
+ return (`Content-Type: ${att.mimeType}; name="${encodedName}"${CRLF}` +
155
+ `Content-Disposition: attachment; filename="${encodedName}"${CRLF}` +
156
+ `Content-Transfer-Encoding: base64${CRLF}${CRLF}` +
157
+ `${folded}${CRLF}`);
158
+ }
159
+ /**
160
+ * Build a MIME message and return its base64url-encoded form,
161
+ * ready for `POST /messages/send` under the `raw` field.
162
+ */
163
+ export function encodeEmail(email) {
164
+ const headers = [];
165
+ if (email.from)
166
+ headers.push(header("From", email.from));
167
+ headers.push(header("To", email.to));
168
+ if (email.cc)
169
+ headers.push(header("Cc", email.cc));
170
+ if (email.bcc)
171
+ headers.push(header("Bcc", email.bcc));
172
+ if (email.replyTo)
173
+ headers.push(header("Reply-To", email.replyTo));
174
+ if (email.inReplyTo)
175
+ headers.push(header("In-Reply-To", email.inReplyTo));
176
+ if (email.references)
177
+ headers.push(header("References", email.references));
178
+ headers.push(header("Subject", encodeHeaderWord(email.subject)));
179
+ headers.push(header("MIME-Version", "1.0"));
180
+ const text = email.text ?? "";
181
+ const html = email.html ?? "";
182
+ const atts = email.attachments ?? [];
183
+ const hasAtt = atts.length > 0;
184
+ const hasBoth = text && html;
185
+ let bodyBlock;
186
+ let rootType;
187
+ if (!hasAtt && !hasBoth) {
188
+ // Flat body.
189
+ const mime = html ? "text/html" : "text/plain";
190
+ const content = html || text || "";
191
+ bodyBlock = textPart(content, mime);
192
+ rootType = ""; // already present in bodyBlock
193
+ }
194
+ else if (!hasAtt && hasBoth) {
195
+ const altBoundary = randomBoundary();
196
+ rootType = `Content-Type: multipart/alternative; boundary="${altBoundary}"${CRLF}${CRLF}`;
197
+ bodyBlock =
198
+ `--${altBoundary}${CRLF}${textPart(text, "text/plain")}` +
199
+ `--${altBoundary}${CRLF}${textPart(html, "text/html")}` +
200
+ `--${altBoundary}--${CRLF}`;
201
+ }
202
+ else {
203
+ // Attachments present → multipart/mixed wrapping.
204
+ const mixedBoundary = randomBoundary();
205
+ rootType = `Content-Type: multipart/mixed; boundary="${mixedBoundary}"${CRLF}${CRLF}`;
206
+ let inner;
207
+ if (hasBoth) {
208
+ const altBoundary = randomBoundary();
209
+ inner =
210
+ `Content-Type: multipart/alternative; boundary="${altBoundary}"${CRLF}${CRLF}` +
211
+ `--${altBoundary}${CRLF}${textPart(text, "text/plain")}` +
212
+ `--${altBoundary}${CRLF}${textPart(html, "text/html")}` +
213
+ `--${altBoundary}--${CRLF}`;
214
+ }
215
+ else {
216
+ const mime = html ? "text/html" : "text/plain";
217
+ const content = html || text || "";
218
+ inner = textPart(content, mime);
219
+ }
220
+ bodyBlock =
221
+ `--${mixedBoundary}${CRLF}${inner}` +
222
+ atts
223
+ .map((a) => `--${mixedBoundary}${CRLF}${attachmentPart(a)}`)
224
+ .join("") +
225
+ `--${mixedBoundary}--${CRLF}`;
226
+ }
227
+ const raw = `${headers.join("")}${rootType}${bodyBlock}`;
228
+ return base64url(raw);
229
+ }
230
+ // ─── Email address helpers ───────────────────────────────────────
231
+ /**
232
+ * Normalize a comma-separated list of addresses into an RFC-5322
233
+ * address list. Bare `addr@x` becomes `<addr@x>`; anything already
234
+ * formatted as `Name <addr@x>` or `<addr@x>` is preserved. Each
235
+ * entry must contain `@`.
236
+ */
237
+ function normalizeAddressList(input) {
238
+ if (!input)
239
+ return undefined;
240
+ const parts = input
241
+ .split(",")
242
+ .map((s) => s.trim())
243
+ .filter((s) => s.length > 0);
244
+ for (const p of parts) {
245
+ if (!p.includes("@")) {
246
+ throw new Error(`gmail: invalid email address "${p}"`);
247
+ }
248
+ }
249
+ return parts
250
+ .map((p) => (p.includes("<") && p.includes(">") ? p : `<${p}>`))
251
+ .join(", ");
252
+ }
253
+ function findHeader(payload, name) {
254
+ if (!payload?.headers)
255
+ return undefined;
256
+ const lower = name.toLowerCase();
257
+ const h = payload.headers.find((h) => h.name.toLowerCase() === lower);
258
+ return h?.value;
259
+ }
260
+ const REPLY_METADATA_HEADERS = [
261
+ "From",
262
+ "To",
263
+ "Cc",
264
+ "Bcc",
265
+ "Reply-To",
266
+ "Subject",
267
+ "Message-ID",
268
+ ];
269
+ // ─── Filter sugar ────────────────────────────────────────────────
270
+ /**
271
+ * Translate a friendly filter bag into Gmail's list query shape.
272
+ *
273
+ * Mirrors n8n's `prepareQuery` helper: `sender`, `readStatus`,
274
+ * `receivedAfter`, `receivedBefore` fold into the `q=` search
275
+ * expression (which itself can be combined with an explicit `q`).
276
+ * `labelIds`, `includeSpamTrash`, `pageToken`, `maxResults` pass
277
+ * through as query parameters.
278
+ */
279
+ function buildListQuery(input) {
280
+ const qs = {};
281
+ const qParts = [];
282
+ if (typeof input.q === "string" && input.q.length > 0)
283
+ qParts.push(input.q);
284
+ if (typeof input.sender === "string" && input.sender.length > 0) {
285
+ qParts.push(`from:${input.sender}`);
286
+ }
287
+ if (input.readStatus === "read" || input.readStatus === "unread") {
288
+ qParts.push(`is:${input.readStatus}`);
289
+ }
290
+ const after = toGmailTimestamp(input.receivedAfter);
291
+ if (after !== undefined)
292
+ qParts.push(`after:${after}`);
293
+ const before = toGmailTimestamp(input.receivedBefore);
294
+ if (before !== undefined)
295
+ qParts.push(`before:${before}`);
296
+ if (qParts.length > 0)
297
+ qs.q = qParts.join(" ");
298
+ if (input.labelIds)
299
+ qs.labelIds = input.labelIds;
300
+ if (input.includeSpamTrash)
301
+ qs.includeSpamTrash = true;
302
+ if (input.pageToken)
303
+ qs.pageToken = input.pageToken;
304
+ return qs;
305
+ }
306
+ function toGmailTimestamp(v) {
307
+ if (v === undefined || v === null || v === "")
308
+ return undefined;
309
+ if (typeof v === "number") {
310
+ // Accept either seconds or milliseconds; Gmail wants seconds.
311
+ return v > 1e12 ? Math.floor(v / 1000) : Math.floor(v);
312
+ }
313
+ if (typeof v === "string") {
314
+ const parsed = Date.parse(v);
315
+ if (!Number.isNaN(parsed))
316
+ return Math.floor(parsed / 1000);
317
+ const num = Number(v);
318
+ if (!Number.isNaN(num))
319
+ return toGmailTimestamp(num);
320
+ }
321
+ return undefined;
322
+ }
323
+ async function loadLabelMap(ctx) {
324
+ const res = (await gmailRequest(ctx, "GET", "/labels"));
325
+ const map = new Map();
326
+ for (const l of res.labels ?? [])
327
+ map.set(l.id, l.name);
328
+ return map;
329
+ }
330
+ /**
331
+ * Walk a Gmail message `payload` tree, collecting decoded text/html
332
+ * bodies and attachment metadata. Binary bytes stay server-side —
333
+ * use `message.getAttachment` to fetch them.
334
+ */
335
+ function walkPayload(payload, acc) {
336
+ if (!payload || typeof payload !== "object")
337
+ return;
338
+ const p = payload;
339
+ if (Array.isArray(p.parts) && p.parts.length > 0) {
340
+ for (const child of p.parts)
341
+ walkPayload(child, acc);
342
+ return;
343
+ }
344
+ const body = p.body;
345
+ if (!body)
346
+ return;
347
+ const isAttachment = !!(p.filename && p.filename.length > 0);
348
+ if (isAttachment) {
349
+ acc.attachments.push({
350
+ attachmentId: body.attachmentId,
351
+ name: p.filename ?? "attachment",
352
+ mimeType: p.mimeType ?? "application/octet-stream",
353
+ size: body.size ?? 0,
354
+ });
355
+ return;
356
+ }
357
+ if (!body.data)
358
+ return;
359
+ const decoded = Buffer.from(body.data, "base64url").toString("utf-8");
360
+ if (p.mimeType === "text/html")
361
+ acc.html.push(decoded);
362
+ else
363
+ acc.text.push(decoded);
364
+ }
365
+ function simplifyMessage(raw, labels) {
366
+ const out = {
367
+ id: raw.id,
368
+ threadId: raw.threadId,
369
+ snippet: raw.snippet,
370
+ sizeEstimate: raw.sizeEstimate,
371
+ internalDate: raw.internalDate,
372
+ };
373
+ const labelIds = raw.labelIds ?? [];
374
+ if (labelIds.length > 0) {
375
+ out.labels = labelIds.map((id) => ({ id, name: labels.get(id) ?? id }));
376
+ }
377
+ const payload = raw.payload;
378
+ if (payload?.headers) {
379
+ const headerMap = {};
380
+ for (const h of payload.headers)
381
+ headerMap[h.name] = h.value;
382
+ out.headers = headerMap;
383
+ for (const key of ["From", "To", "Cc", "Bcc", "Subject"]) {
384
+ const v = findHeader(payload, key);
385
+ if (v !== undefined)
386
+ out[key] = v;
387
+ }
388
+ }
389
+ // Body + attachments only meaningful when format=full was used.
390
+ const acc = { text: [], html: [], attachments: [] };
391
+ walkPayload(payload, acc);
392
+ if (acc.text.length > 0)
393
+ out.text = acc.text.join("\n");
394
+ if (acc.html.length > 0)
395
+ out.html = acc.html.join("\n");
396
+ if (acc.attachments.length > 0)
397
+ out.attachments = acc.attachments;
398
+ return out;
399
+ }
400
+ /**
401
+ * Shared reply implementation for message.reply and thread.reply.
402
+ * Fetches the original message's headers, derives recipients based
403
+ * on `replyToSenderOnly` / `replyToRecipientsOnly`, filters out the
404
+ * authenticated user's own address, and sends with `In-Reply-To` +
405
+ * `References` headers plus the original `threadId`.
406
+ */
407
+ async function replyToMessage(ctx, messageId, p) {
408
+ if (p.replyToSenderOnly && p.replyToRecipientsOnly) {
409
+ throw new Error("gmail: replyToSenderOnly and replyToRecipientsOnly are mutually exclusive");
410
+ }
411
+ const original = (await gmailRequest(ctx, "GET", `/messages/${messageId}`, undefined, { format: "metadata", metadataHeaders: REPLY_METADATA_HEADERS }));
412
+ const subject = findHeader(original.payload, "Subject") ?? "";
413
+ const messageIdHeader = findHeader(original.payload, "Message-ID") ?? "";
414
+ const threadId = original.threadId;
415
+ const profile = (await gmailRequest(ctx, "GET", "/profile"));
416
+ const self = profile.emailAddress.toLowerCase();
417
+ const toList = [];
418
+ const replyToHeader = findHeader(original.payload, "Reply-To");
419
+ const fromHeader = findHeader(original.payload, "From");
420
+ const toHeader = findHeader(original.payload, "To");
421
+ if (!p.replyToRecipientsOnly) {
422
+ const src = replyToHeader || fromHeader;
423
+ if (src)
424
+ toList.push(src);
425
+ }
426
+ if (!p.replyToSenderOnly && toHeader) {
427
+ for (const raw of toHeader.split(",")) {
428
+ const entry = raw.trim();
429
+ if (!entry)
430
+ continue;
431
+ if (entry.toLowerCase().includes(self))
432
+ continue;
433
+ toList.push(entry);
434
+ }
435
+ }
436
+ const seen = new Set();
437
+ const to = toList
438
+ .filter((addr) => {
439
+ const key = addr.toLowerCase();
440
+ if (seen.has(key))
441
+ return false;
442
+ seen.add(key);
443
+ return true;
444
+ })
445
+ .map((addr) => addr.includes("<") && addr.includes(">") ? addr : `<${addr}>`)
446
+ .join(", ");
447
+ const email = {
448
+ to,
449
+ cc: normalizeAddressList(p.cc),
450
+ bcc: normalizeAddressList(p.bcc),
451
+ subject: subject.toLowerCase().startsWith("re:") ? subject : `Re: ${subject}`,
452
+ text: p.text,
453
+ html: p.html,
454
+ inReplyTo: messageIdHeader,
455
+ references: messageIdHeader,
456
+ attachments: p.attachments,
457
+ };
458
+ const body = { raw: encodeEmail(email) };
459
+ if (threadId)
460
+ body.threadId = threadId;
461
+ return gmailRequest(ctx, "POST", "/messages/send", body);
462
+ }
463
+ // ─── Plugin ──────────────────────────────────────────────────────
464
+ const SCOPES = [
465
+ "https://mail.google.com/",
466
+ "https://www.googleapis.com/auth/gmail.modify",
467
+ "https://www.googleapis.com/auth/gmail.compose",
468
+ "https://www.googleapis.com/auth/gmail.labels",
469
+ ];
470
+ export default function gmail(rl) {
471
+ rl.setName("gmail");
472
+ rl.setVersion("0.1.0");
473
+ rl.setOAuth({
474
+ authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
475
+ tokenUrl: "https://oauth2.googleapis.com/token",
476
+ scopes: SCOPES,
477
+ // access_type=offline + prompt=consent are both required to
478
+ // make Google return a refresh token. Without them, repeat
479
+ // logins skip the refresh_token field in the response.
480
+ authParams: { access_type: "offline", prompt: "consent" },
481
+ setupHelp: [
482
+ "You need a Google Cloud OAuth client. Takes ~5 minutes, one time.",
483
+ "Do the steps in order — skipping step 4 causes a 403 when you log in.",
484
+ "",
485
+ "1. Create a Google Cloud project (or pick an existing one):",
486
+ " https://console.cloud.google.com/projectcreate",
487
+ " Make sure the new project is selected in the top navigation dropdown.",
488
+ "",
489
+ "2. Enable the Gmail API for that project:",
490
+ " https://console.cloud.google.com/apis/library/gmail.googleapis.com",
491
+ " Click 'Enable'.",
492
+ "",
493
+ "3. Configure the OAuth consent screen (first time only):",
494
+ " https://console.cloud.google.com/apis/credentials/consent",
495
+ " Click 'Get started' and fill in:",
496
+ " • App name: runline (or whatever)",
497
+ " • User support email: your email",
498
+ " • Audience: External",
499
+ " • Developer contact: your email",
500
+ " You can skip the Scopes screen — runline declares scopes at auth time.",
501
+ "",
502
+ "4. Add yourself as a test user (required — External apps in Testing mode",
503
+ " only allow authentication from addresses on this list):",
504
+ " https://console.cloud.google.com/auth/audience",
505
+ " Under 'Test users', click '+ Add users' and enter the Gmail address",
506
+ " you'll use to log in. Consent and refresh tokens expire every 7 days",
507
+ " while the app is in Testing mode.",
508
+ "",
509
+ "5. Create the OAuth client:",
510
+ " https://console.cloud.google.com/apis/credentials",
511
+ " • + Create credentials → OAuth client ID",
512
+ " • Application type: Web application",
513
+ " • Name: runline (or whatever)",
514
+ " • Authorized redirect URIs → + Add URI:",
515
+ " {{redirectUri}}",
516
+ " • Click Create, then copy the Client ID and Client Secret from the",
517
+ " dialog — the secret is only shown once.",
518
+ "",
519
+ "6. Paste them below (or re-run with --client-id / --client-secret,",
520
+ " or export GMAIL_CLIENT_ID and GMAIL_CLIENT_SECRET).",
521
+ "",
522
+ "During login you'll see a 'Google hasn't verified this app' warning.",
523
+ "Click 'Advanced → Go to runline (unsafe)' to continue — this is",
524
+ "expected for apps in Testing mode.",
525
+ ],
526
+ });
527
+ rl.setConnectionSchema({
528
+ clientId: {
529
+ type: "string",
530
+ required: true,
531
+ description: "Google OAuth2 client ID",
532
+ env: "GMAIL_CLIENT_ID",
533
+ },
534
+ clientSecret: {
535
+ type: "string",
536
+ required: true,
537
+ description: "Google OAuth2 client secret",
538
+ env: "GMAIL_CLIENT_SECRET",
539
+ },
540
+ refreshToken: {
541
+ type: "string",
542
+ required: true,
543
+ description: "OAuth2 refresh token (obtained via login flow)",
544
+ env: "GMAIL_REFRESH_TOKEN",
545
+ },
546
+ accessToken: {
547
+ type: "string",
548
+ required: false,
549
+ description: "Cached access token (auto-refreshed)",
550
+ },
551
+ accessTokenExpiresAt: {
552
+ type: "number",
553
+ required: false,
554
+ description: "Cached access token expiry (ms since epoch)",
555
+ },
556
+ });
557
+ // ── Message ───────────────────────────────────────────
558
+ rl.registerAction("message.send", {
559
+ description: "Send an email",
560
+ inputSchema: {
561
+ to: {
562
+ type: "string",
563
+ required: true,
564
+ description: "Comma-separated recipient list",
565
+ },
566
+ subject: { type: "string", required: true },
567
+ text: { type: "string", required: false, description: "Plain body" },
568
+ html: { type: "string", required: false, description: "HTML body" },
569
+ cc: { type: "string", required: false },
570
+ bcc: { type: "string", required: false },
571
+ replyTo: { type: "string", required: false },
572
+ from: {
573
+ type: "string",
574
+ required: false,
575
+ description: 'Override From (e.g. "Name <me@x.com>")',
576
+ },
577
+ threadId: { type: "string", required: false },
578
+ attachments: {
579
+ type: "array",
580
+ required: false,
581
+ description: "[{name, mimeType, contentBase64}]",
582
+ },
583
+ },
584
+ async execute(input, ctx) {
585
+ const p = input;
586
+ const email = {
587
+ to: normalizeAddressList(p.to),
588
+ cc: normalizeAddressList(p.cc),
589
+ bcc: normalizeAddressList(p.bcc),
590
+ replyTo: normalizeAddressList(p.replyTo),
591
+ from: p.from,
592
+ subject: p.subject ?? "",
593
+ text: p.text,
594
+ html: p.html,
595
+ attachments: p.attachments,
596
+ };
597
+ const body = { raw: encodeEmail(email) };
598
+ if (p.threadId)
599
+ body.threadId = p.threadId;
600
+ return gmailRequest(ctx, "POST", "/messages/send", body);
601
+ },
602
+ });
603
+ rl.registerAction("message.reply", {
604
+ description: "Reply to a message, preserving threadId and In-Reply-To/References headers",
605
+ inputSchema: {
606
+ messageId: { type: "string", required: true },
607
+ text: { type: "string", required: false },
608
+ html: { type: "string", required: false },
609
+ cc: { type: "string", required: false },
610
+ bcc: { type: "string", required: false },
611
+ replyToSenderOnly: { type: "boolean", required: false },
612
+ replyToRecipientsOnly: { type: "boolean", required: false },
613
+ attachments: { type: "array", required: false },
614
+ },
615
+ async execute(input, ctx) {
616
+ const p = input;
617
+ return replyToMessage(ctx, p.messageId, p);
618
+ },
619
+ });
620
+ rl.registerAction("message.get", {
621
+ description: "Get a message by ID",
622
+ inputSchema: {
623
+ id: { type: "string", required: true },
624
+ format: {
625
+ type: "string",
626
+ required: false,
627
+ description: "minimal | full | raw | metadata (default: full)",
628
+ },
629
+ metadataHeaders: { type: "array", required: false },
630
+ simple: {
631
+ type: "boolean",
632
+ required: false,
633
+ description: "Flatten headers, resolve labels to names, and decode text/html bodies",
634
+ },
635
+ },
636
+ async execute(input, ctx) {
637
+ const p = input;
638
+ const qs = { format: p.format ?? "full" };
639
+ if (p.metadataHeaders)
640
+ qs.metadataHeaders = p.metadataHeaders;
641
+ const raw = (await gmailRequest(ctx, "GET", `/messages/${p.id}`, undefined, qs));
642
+ if (!p.simple)
643
+ return raw;
644
+ const labels = await loadLabelMap(ctx);
645
+ return simplifyMessage(raw, labels);
646
+ },
647
+ });
648
+ rl.registerAction("message.list", {
649
+ description: "List messages. Supports Gmail search syntax via `q`, plus friendly filters: sender, readStatus ('read'|'unread'), receivedAfter/Before (ISO string, ms, or seconds).",
650
+ inputSchema: {
651
+ q: { type: "string", required: false, description: "Gmail search query" },
652
+ sender: { type: "string", required: false },
653
+ readStatus: {
654
+ type: "string",
655
+ required: false,
656
+ description: "read | unread | both (default: both)",
657
+ },
658
+ receivedAfter: {
659
+ type: "string",
660
+ required: false,
661
+ description: "ISO datetime, epoch ms, or epoch seconds",
662
+ },
663
+ receivedBefore: { type: "string", required: false },
664
+ labelIds: { type: "array", required: false },
665
+ maxResults: { type: "number", required: false },
666
+ pageToken: { type: "string", required: false },
667
+ includeSpamTrash: { type: "boolean", required: false },
668
+ returnAll: {
669
+ type: "boolean",
670
+ required: false,
671
+ description: "Paginate until exhausted",
672
+ },
673
+ },
674
+ async execute(input, ctx) {
675
+ const p = input;
676
+ const qs = buildListQuery(p);
677
+ if (p.returnAll) {
678
+ return paginateAll(ctx, "/messages", "messages", qs);
679
+ }
680
+ if (p.maxResults)
681
+ qs.maxResults = p.maxResults;
682
+ return gmailRequest(ctx, "GET", "/messages", undefined, qs);
683
+ },
684
+ });
685
+ rl.registerAction("message.delete", {
686
+ description: "Permanently delete a message",
687
+ inputSchema: { id: { type: "string", required: true } },
688
+ async execute(input, ctx) {
689
+ const { id } = input;
690
+ return gmailRequest(ctx, "DELETE", `/messages/${id}`);
691
+ },
692
+ });
693
+ rl.registerAction("message.trash", {
694
+ description: "Move a message to trash (recoverable)",
695
+ inputSchema: { id: { type: "string", required: true } },
696
+ async execute(input, ctx) {
697
+ const { id } = input;
698
+ return gmailRequest(ctx, "POST", `/messages/${id}/trash`);
699
+ },
700
+ });
701
+ rl.registerAction("message.untrash", {
702
+ description: "Remove a message from trash",
703
+ inputSchema: { id: { type: "string", required: true } },
704
+ async execute(input, ctx) {
705
+ const { id } = input;
706
+ return gmailRequest(ctx, "POST", `/messages/${id}/untrash`);
707
+ },
708
+ });
709
+ rl.registerAction("message.markAsRead", {
710
+ description: "Remove the UNREAD label",
711
+ inputSchema: { id: { type: "string", required: true } },
712
+ async execute(input, ctx) {
713
+ const { id } = input;
714
+ return gmailRequest(ctx, "POST", `/messages/${id}/modify`, {
715
+ removeLabelIds: ["UNREAD"],
716
+ });
717
+ },
718
+ });
719
+ rl.registerAction("message.markAsUnread", {
720
+ description: "Add the UNREAD label",
721
+ inputSchema: { id: { type: "string", required: true } },
722
+ async execute(input, ctx) {
723
+ const { id } = input;
724
+ return gmailRequest(ctx, "POST", `/messages/${id}/modify`, {
725
+ addLabelIds: ["UNREAD"],
726
+ });
727
+ },
728
+ });
729
+ rl.registerAction("message.addLabels", {
730
+ description: "Add labels to a message",
731
+ inputSchema: {
732
+ id: { type: "string", required: true },
733
+ labelIds: { type: "array", required: true },
734
+ },
735
+ async execute(input, ctx) {
736
+ const { id, labelIds } = input;
737
+ return gmailRequest(ctx, "POST", `/messages/${id}/modify`, {
738
+ addLabelIds: labelIds,
739
+ });
740
+ },
741
+ });
742
+ rl.registerAction("message.removeLabels", {
743
+ description: "Remove labels from a message",
744
+ inputSchema: {
745
+ id: { type: "string", required: true },
746
+ labelIds: { type: "array", required: true },
747
+ },
748
+ async execute(input, ctx) {
749
+ const { id, labelIds } = input;
750
+ return gmailRequest(ctx, "POST", `/messages/${id}/modify`, {
751
+ removeLabelIds: labelIds,
752
+ });
753
+ },
754
+ });
755
+ rl.registerAction("message.getAttachment", {
756
+ description: "Download an attachment by ID (returns {size, data} where data is base64url)",
757
+ inputSchema: {
758
+ messageId: { type: "string", required: true },
759
+ attachmentId: { type: "string", required: true },
760
+ },
761
+ async execute(input, ctx) {
762
+ const { messageId, attachmentId } = input;
763
+ return gmailRequest(ctx, "GET", `/messages/${messageId}/attachments/${attachmentId}`);
764
+ },
765
+ });
766
+ // ── Thread ────────────────────────────────────────────
767
+ rl.registerAction("thread.get", {
768
+ description: "Get a thread by ID",
769
+ inputSchema: {
770
+ id: { type: "string", required: true },
771
+ format: { type: "string", required: false, description: "minimal | full | metadata" },
772
+ metadataHeaders: { type: "array", required: false },
773
+ simple: {
774
+ type: "boolean",
775
+ required: false,
776
+ description: "Return an array of simplified messages instead of the raw thread",
777
+ },
778
+ },
779
+ async execute(input, ctx) {
780
+ const p = input;
781
+ const qs = { format: p.format ?? "full" };
782
+ if (p.metadataHeaders)
783
+ qs.metadataHeaders = p.metadataHeaders;
784
+ const raw = (await gmailRequest(ctx, "GET", `/threads/${p.id}`, undefined, qs));
785
+ if (!p.simple)
786
+ return raw;
787
+ const labels = await loadLabelMap(ctx);
788
+ return (raw.messages ?? []).map((m) => simplifyMessage(m, labels));
789
+ },
790
+ });
791
+ rl.registerAction("thread.list", {
792
+ description: "List threads. Same filter sugar as `message.list` (sender, readStatus, receivedAfter/Before).",
793
+ inputSchema: {
794
+ q: { type: "string", required: false },
795
+ sender: { type: "string", required: false },
796
+ readStatus: { type: "string", required: false },
797
+ receivedAfter: { type: "string", required: false },
798
+ receivedBefore: { type: "string", required: false },
799
+ labelIds: { type: "array", required: false },
800
+ maxResults: { type: "number", required: false },
801
+ pageToken: { type: "string", required: false },
802
+ includeSpamTrash: { type: "boolean", required: false },
803
+ returnAll: { type: "boolean", required: false },
804
+ },
805
+ async execute(input, ctx) {
806
+ const p = input;
807
+ const qs = buildListQuery(p);
808
+ if (p.returnAll)
809
+ return paginateAll(ctx, "/threads", "threads", qs);
810
+ if (p.maxResults)
811
+ qs.maxResults = p.maxResults;
812
+ return gmailRequest(ctx, "GET", "/threads", undefined, qs);
813
+ },
814
+ });
815
+ rl.registerAction("thread.delete", {
816
+ description: "Permanently delete a thread",
817
+ inputSchema: { id: { type: "string", required: true } },
818
+ async execute(input, ctx) {
819
+ const { id } = input;
820
+ return gmailRequest(ctx, "DELETE", `/threads/${id}`);
821
+ },
822
+ });
823
+ rl.registerAction("thread.trash", {
824
+ description: "Move a thread to trash",
825
+ inputSchema: { id: { type: "string", required: true } },
826
+ async execute(input, ctx) {
827
+ const { id } = input;
828
+ return gmailRequest(ctx, "POST", `/threads/${id}/trash`);
829
+ },
830
+ });
831
+ rl.registerAction("thread.untrash", {
832
+ description: "Remove a thread from trash",
833
+ inputSchema: { id: { type: "string", required: true } },
834
+ async execute(input, ctx) {
835
+ const { id } = input;
836
+ return gmailRequest(ctx, "POST", `/threads/${id}/untrash`);
837
+ },
838
+ });
839
+ rl.registerAction("thread.addLabels", {
840
+ description: "Add labels to all messages in a thread",
841
+ inputSchema: {
842
+ id: { type: "string", required: true },
843
+ labelIds: { type: "array", required: true },
844
+ },
845
+ async execute(input, ctx) {
846
+ const { id, labelIds } = input;
847
+ return gmailRequest(ctx, "POST", `/threads/${id}/modify`, {
848
+ addLabelIds: labelIds,
849
+ });
850
+ },
851
+ });
852
+ rl.registerAction("thread.removeLabels", {
853
+ description: "Remove labels from all messages in a thread",
854
+ inputSchema: {
855
+ id: { type: "string", required: true },
856
+ labelIds: { type: "array", required: true },
857
+ },
858
+ async execute(input, ctx) {
859
+ const { id, labelIds } = input;
860
+ return gmailRequest(ctx, "POST", `/threads/${id}/modify`, {
861
+ removeLabelIds: labelIds,
862
+ });
863
+ },
864
+ });
865
+ rl.registerAction("thread.reply", {
866
+ description: "Reply to the last message in a thread (convenience wrapper over message.reply)",
867
+ inputSchema: {
868
+ id: { type: "string", required: true, description: "Thread ID" },
869
+ text: { type: "string", required: false },
870
+ html: { type: "string", required: false },
871
+ cc: { type: "string", required: false },
872
+ bcc: { type: "string", required: false },
873
+ replyToSenderOnly: { type: "boolean", required: false },
874
+ replyToRecipientsOnly: { type: "boolean", required: false },
875
+ attachments: { type: "array", required: false },
876
+ },
877
+ async execute(input, ctx) {
878
+ const p = input;
879
+ const thread = (await gmailRequest(ctx, "GET", `/threads/${p.id}`, undefined, { format: "minimal" }));
880
+ const last = thread.messages?.[thread.messages.length - 1];
881
+ if (!last?.id) {
882
+ throw new Error(`gmail: thread ${p.id} has no messages to reply to`);
883
+ }
884
+ return replyToMessage(ctx, last.id, p);
885
+ },
886
+ });
887
+ // ── Draft ─────────────────────────────────────────────
888
+ rl.registerAction("draft.create", {
889
+ description: "Create a draft",
890
+ inputSchema: {
891
+ to: { type: "string", required: false },
892
+ subject: { type: "string", required: false },
893
+ text: { type: "string", required: false },
894
+ html: { type: "string", required: false },
895
+ cc: { type: "string", required: false },
896
+ bcc: { type: "string", required: false },
897
+ replyTo: { type: "string", required: false },
898
+ from: { type: "string", required: false },
899
+ fromAlias: {
900
+ type: "string",
901
+ required: false,
902
+ description: "Send-as alias address (e.g. 'me+alt@x.com'); sets the From header",
903
+ },
904
+ threadId: { type: "string", required: false },
905
+ attachments: { type: "array", required: false },
906
+ },
907
+ async execute(input, ctx) {
908
+ const p = input;
909
+ const from = p.from ?? p.fromAlias;
910
+ const email = {
911
+ to: normalizeAddressList(p.to) ?? "",
912
+ cc: normalizeAddressList(p.cc),
913
+ bcc: normalizeAddressList(p.bcc),
914
+ replyTo: normalizeAddressList(p.replyTo),
915
+ from,
916
+ subject: p.subject ?? "",
917
+ text: p.text,
918
+ html: p.html,
919
+ attachments: p.attachments,
920
+ };
921
+ // When threading a draft, fetch the last Message-ID in the
922
+ // thread and set In-Reply-To/References so Gmail places the
923
+ // draft into the conversation correctly.
924
+ if (p.threadId) {
925
+ const thread = (await gmailRequest(ctx, "GET", `/threads/${p.threadId}`, undefined, { format: "metadata", metadataHeaders: ["Message-ID"] }));
926
+ const last = thread.messages?.[thread.messages.length - 1];
927
+ const mid = findHeader(last?.payload, "Message-ID");
928
+ if (mid) {
929
+ email.inReplyTo = mid;
930
+ email.references = mid;
931
+ }
932
+ }
933
+ const message = { raw: encodeEmail(email) };
934
+ if (p.threadId)
935
+ message.threadId = p.threadId;
936
+ return gmailRequest(ctx, "POST", "/drafts", { message });
937
+ },
938
+ });
939
+ rl.registerAction("draft.get", {
940
+ description: "Get a draft by ID",
941
+ inputSchema: {
942
+ id: { type: "string", required: true },
943
+ format: { type: "string", required: false },
944
+ },
945
+ async execute(input, ctx) {
946
+ const p = input;
947
+ const qs = { format: p.format ?? "full" };
948
+ return gmailRequest(ctx, "GET", `/drafts/${p.id}`, undefined, qs);
949
+ },
950
+ });
951
+ rl.registerAction("draft.list", {
952
+ description: "List drafts",
953
+ inputSchema: {
954
+ q: { type: "string", required: false },
955
+ maxResults: { type: "number", required: false },
956
+ pageToken: { type: "string", required: false },
957
+ includeSpamTrash: { type: "boolean", required: false },
958
+ returnAll: { type: "boolean", required: false },
959
+ },
960
+ async execute(input, ctx) {
961
+ const p = input;
962
+ const qs = {};
963
+ if (p.q)
964
+ qs.q = p.q;
965
+ if (p.includeSpamTrash)
966
+ qs.includeSpamTrash = true;
967
+ if (p.pageToken)
968
+ qs.pageToken = p.pageToken;
969
+ if (p.returnAll)
970
+ return paginateAll(ctx, "/drafts", "drafts", qs);
971
+ if (p.maxResults)
972
+ qs.maxResults = p.maxResults;
973
+ return gmailRequest(ctx, "GET", "/drafts", undefined, qs);
974
+ },
975
+ });
976
+ rl.registerAction("draft.delete", {
977
+ description: "Delete a draft",
978
+ inputSchema: { id: { type: "string", required: true } },
979
+ async execute(input, ctx) {
980
+ const { id } = input;
981
+ return gmailRequest(ctx, "DELETE", `/drafts/${id}`);
982
+ },
983
+ });
984
+ rl.registerAction("draft.send", {
985
+ description: "Send an existing draft",
986
+ inputSchema: { id: { type: "string", required: true } },
987
+ async execute(input, ctx) {
988
+ const { id } = input;
989
+ return gmailRequest(ctx, "POST", "/drafts/send", { id });
990
+ },
991
+ });
992
+ // ── Label ─────────────────────────────────────────────
993
+ rl.registerAction("label.create", {
994
+ description: "Create a label",
995
+ inputSchema: {
996
+ name: { type: "string", required: true },
997
+ labelListVisibility: {
998
+ type: "string",
999
+ required: false,
1000
+ description: "labelShow | labelShowIfUnread | labelHide",
1001
+ },
1002
+ messageListVisibility: {
1003
+ type: "string",
1004
+ required: false,
1005
+ description: "show | hide",
1006
+ },
1007
+ },
1008
+ async execute(input, ctx) {
1009
+ const p = input;
1010
+ const body = { name: p.name };
1011
+ if (p.labelListVisibility)
1012
+ body.labelListVisibility = p.labelListVisibility;
1013
+ if (p.messageListVisibility)
1014
+ body.messageListVisibility = p.messageListVisibility;
1015
+ return gmailRequest(ctx, "POST", "/labels", body);
1016
+ },
1017
+ });
1018
+ rl.registerAction("label.get", {
1019
+ description: "Get a label by ID",
1020
+ inputSchema: { id: { type: "string", required: true } },
1021
+ async execute(input, ctx) {
1022
+ const { id } = input;
1023
+ return gmailRequest(ctx, "GET", `/labels/${id}`);
1024
+ },
1025
+ });
1026
+ rl.registerAction("label.list", {
1027
+ description: "List all labels",
1028
+ async execute(_input, ctx) {
1029
+ const res = (await gmailRequest(ctx, "GET", "/labels"));
1030
+ return res.labels ?? [];
1031
+ },
1032
+ });
1033
+ rl.registerAction("label.delete", {
1034
+ description: "Delete a label",
1035
+ inputSchema: { id: { type: "string", required: true } },
1036
+ async execute(input, ctx) {
1037
+ const { id } = input;
1038
+ return gmailRequest(ctx, "DELETE", `/labels/${id}`);
1039
+ },
1040
+ });
1041
+ rl.registerAction("label.update", {
1042
+ description: "Update a label",
1043
+ inputSchema: {
1044
+ id: { type: "string", required: true },
1045
+ name: { type: "string", required: false },
1046
+ labelListVisibility: { type: "string", required: false },
1047
+ messageListVisibility: { type: "string", required: false },
1048
+ },
1049
+ async execute(input, ctx) {
1050
+ const p = input;
1051
+ const body = {};
1052
+ if (p.name)
1053
+ body.name = p.name;
1054
+ if (p.labelListVisibility)
1055
+ body.labelListVisibility = p.labelListVisibility;
1056
+ if (p.messageListVisibility)
1057
+ body.messageListVisibility = p.messageListVisibility;
1058
+ return gmailRequest(ctx, "PATCH", `/labels/${p.id}`, body);
1059
+ },
1060
+ });
1061
+ // ── Profile / aliases ─────────────────────────────────
1062
+ rl.registerAction("profile.get", {
1063
+ description: "Get the authenticated user's profile",
1064
+ async execute(_input, ctx) {
1065
+ return gmailRequest(ctx, "GET", "/profile");
1066
+ },
1067
+ });
1068
+ rl.registerAction("alias.list", {
1069
+ description: "List configured send-as aliases",
1070
+ async execute(_input, ctx) {
1071
+ const res = (await gmailRequest(ctx, "GET", "/settings/sendAs"));
1072
+ return res.sendAs ?? [];
1073
+ },
1074
+ });
1075
+ }