@stage-labs/metro 0.1.0-beta.219 → 0.1.0-beta.220

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.
Files changed (50) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  3. package/runtime/node_modules/@metro-labs/core/package.json +4 -0
  4. package/runtime/node_modules/@metro-labs/core/src/lines.ts +3 -3
  5. package/runtime/node_modules/@metro-labs/core/src/station-names.ts +1 -0
  6. package/runtime/node_modules/@metro-labs/core/src/stations/mail-trust.ts +29 -0
  7. package/runtime/node_modules/@metro-labs/core/src/stations/mail.ts +212 -0
  8. package/runtime/node_modules/@metro-labs/core/src/stations/oauth.ts +126 -0
  9. package/runtime/node_modules/@metro-labs/daemon/package.json +1 -0
  10. package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +1 -1
  11. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-schemas.ts +6 -4
  12. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-browser.ts +102 -0
  13. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-interactive.ts +3 -2
  14. package/runtime/node_modules/@metro-labs/daemon/src/stations/materialize.ts +5 -0
  15. package/runtime/node_modules/@metro-labs/daemon/src/stations/registry.ts +2 -0
  16. package/runtime/node_modules/@metro-labs/gmail/package.json +25 -0
  17. package/runtime/node_modules/@metro-labs/gmail/src/accounts.ts +81 -0
  18. package/runtime/node_modules/@metro-labs/gmail/src/actions.ts +14 -0
  19. package/runtime/node_modules/@metro-labs/gmail/src/api.ts +31 -0
  20. package/runtime/node_modules/@metro-labs/gmail/src/attachments.ts +35 -0
  21. package/runtime/node_modules/@metro-labs/gmail/src/auth.ts +105 -0
  22. package/runtime/node_modules/@metro-labs/gmail/src/config.ts +13 -0
  23. package/runtime/node_modules/@metro-labs/gmail/src/inbound.ts +96 -0
  24. package/runtime/node_modules/@metro-labs/gmail/src/index.ts +12 -0
  25. package/runtime/node_modules/@metro-labs/gmail/src/login.ts +37 -0
  26. package/runtime/node_modules/@metro-labs/gmail/src/message.ts +125 -0
  27. package/runtime/node_modules/@metro-labs/gmail/src/mime.ts +70 -0
  28. package/runtime/node_modules/@metro-labs/gmail/src/outbound.ts +124 -0
  29. package/runtime/node_modules/@metro-labs/gmail/src/query.ts +99 -0
  30. package/runtime/node_modules/@metro-labs/gmail/src/state.ts +24 -0
  31. package/runtime/node_modules/@metro-labs/gmail/src/station.ts +15 -0
  32. package/runtime/node_modules/@metro-labs/gmail/src/trust.ts +42 -0
  33. package/runtime/node_modules/@metro-labs/outlook/src/accounts.ts +14 -29
  34. package/runtime/node_modules/@metro-labs/outlook/src/attachments.ts +1 -17
  35. package/runtime/node_modules/@metro-labs/outlook/src/auth.ts +12 -42
  36. package/runtime/node_modules/@metro-labs/outlook/src/browser.ts +5 -16
  37. package/runtime/node_modules/@metro-labs/outlook/src/config.ts +6 -19
  38. package/runtime/node_modules/@metro-labs/outlook/src/format.ts +17 -98
  39. package/runtime/node_modules/@metro-labs/outlook/src/graph.ts +4 -9
  40. package/runtime/node_modules/@metro-labs/outlook/src/inbound.ts +8 -16
  41. package/runtime/node_modules/@metro-labs/outlook/src/login.ts +15 -36
  42. package/runtime/node_modules/@metro-labs/outlook/src/me.ts +5 -15
  43. package/runtime/node_modules/@metro-labs/outlook/src/outbound.ts +8 -21
  44. package/runtime/node_modules/@metro-labs/outlook/src/query.ts +10 -45
  45. package/runtime/node_modules/@metro-labs/outlook/src/state.ts +8 -23
  46. package/runtime/node_modules/@metro-labs/outlook/src/trust.ts +7 -36
  47. package/runtime/runtime.json +1 -1
  48. package/runtime/stations.json +1 -0
  49. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-outlook.ts +0 -63
  50. /package/runtime/node_modules/@metro-labs/{outlook/src/html.ts → core/src/stations/mail-text.ts} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.219",
3
+ "version": "0.1.0-beta.220",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.219",
3
+ "version": "0.1.0-beta.220",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -23,6 +23,10 @@
23
23
  "./stations/station-runtime": "./src/stations/station-runtime.ts",
24
24
  "./stations/account-store": "./src/stations/account-store.ts",
25
25
  "./stations/attachments": "./src/stations/attachments.ts",
26
+ "./stations/mail": "./src/stations/mail.ts",
27
+ "./stations/mail-text": "./src/stations/mail-text.ts",
28
+ "./stations/mail-trust": "./src/stations/mail-trust.ts",
29
+ "./stations/oauth": "./src/stations/oauth.ts",
26
30
  "./stations/messaging-normalize": "./src/stations/messaging-normalize.ts",
27
31
  "./stations/profile": "./src/stations/profile.ts",
28
32
  "./stations/sender-profile": "./src/stations/sender-profile.ts",
@@ -21,7 +21,7 @@ function parseAccountScoped(
21
21
  const isSnowflake = (s: string): boolean => /^\d+$/.test(s);
22
22
  const isThreemaResource = (s: string): boolean =>
23
23
  /^(?:[A-Z0-9]{8}|\*[A-Z0-9]{7})(?:-[0-9a-f]{16})?$/i.test(s);
24
- const isOutlookResource = (s: string): boolean => /^[A-Za-z0-9=_%.@+-]+$/.test(s);
24
+ const isMailResource = (s: string): boolean => /^[A-Za-z0-9=_%.@+-]+$/.test(s);
25
25
  const isSignedInt = (s: string): boolean => /^-?\d+$/.test(s);
26
26
 
27
27
  const topicPart = (topicId: string | undefined): { topicId?: number } | null => {
@@ -73,6 +73,6 @@ export const Line = {
73
73
  parseThreema: (line: Line | string) =>
74
74
  parseAccountScoped(line, 'threema', isThreemaResource),
75
75
 
76
- parseOutlook: (line: Line | string) =>
77
- parseAccountScoped(line, 'outlook', isOutlookResource),
76
+ parseMail: (line: Line | string, station: 'outlook' | 'gmail') =>
77
+ parseAccountScoped(line, station, isMailResource),
78
78
  };
@@ -6,6 +6,7 @@ export const STATIONS = [
6
6
  'whatsapp',
7
7
  'threema',
8
8
  'outlook',
9
+ 'gmail',
9
10
  'webhook',
10
11
  ] as const;
11
12
 
@@ -0,0 +1,29 @@
1
+ export interface MailHeader {
2
+ name?: string;
3
+ value?: string;
4
+ }
5
+
6
+ export type Screened = { skip: string } | { verified: boolean };
7
+
8
+ const AUTOMATED_LOCAL = /^(no-?reply|do-?not-?reply|donotreply|mailer-daemon|notifications?)([+._-].*)?$/i;
9
+ const BULK = new Set(['bulk', 'list', 'junk']);
10
+
11
+ export const headerValues = (headers: MailHeader[], name: string): string[] =>
12
+ headers.filter((h) => (h.name ?? '').toLowerCase() === name).map((h) => (h.value ?? '').trim());
13
+
14
+ export const firstHeader = (headers: MailHeader[], name: string): string | undefined => headerValues(headers, name)[0];
15
+
16
+ export function automatedBySender(address: string): string | null {
17
+ const local = address.split('@')[0] ?? '';
18
+ return AUTOMATED_LOCAL.test(local) ? `sender ${address}` : null;
19
+ }
20
+
21
+ export function automatedByHeaders(headers: MailHeader[]): string | null {
22
+ if (firstHeader(headers, 'list-unsubscribe') !== undefined) return 'List-Unsubscribe';
23
+ const auto = firstHeader(headers, 'auto-submitted');
24
+ if (auto !== undefined && auto.toLowerCase() !== 'no') return `Auto-Submitted: ${auto}`;
25
+ const precedence = (firstHeader(headers, 'precedence') ?? '').toLowerCase();
26
+ return BULK.has(precedence) ? `Precedence: ${precedence}` : null;
27
+ }
28
+
29
+ export const domainOf = (address: string): string => (address.split('@')[1] ?? '').toLowerCase();
@@ -0,0 +1,212 @@
1
+ import { mintId } from '../ids.js';
2
+ import { Line } from '../lines.js';
3
+ import { TrainError } from '../train-error.js';
4
+ import { capText } from './mail-text.js';
5
+
6
+ export type MailStation = 'outlook' | 'gmail';
7
+
8
+ type Args = Record<string, unknown>;
9
+
10
+ export interface MailItem {
11
+ id: string;
12
+ threadId: string;
13
+ subject: string;
14
+ from: string;
15
+ fromName: string;
16
+ to: string[];
17
+ cc: string[];
18
+ date: string | null;
19
+ preview: string;
20
+ text: string;
21
+ hasAttachments: boolean;
22
+ isRead: boolean;
23
+ internetMessageId: string | null;
24
+ }
25
+
26
+ export interface AttachmentMeta {
27
+ kind: string;
28
+ name: string;
29
+ mime: string;
30
+ size?: number;
31
+ }
32
+
33
+ export interface OutgoingFile {
34
+ path: string;
35
+ mime: string;
36
+ name: string;
37
+ }
38
+
39
+ export interface ReadQuery {
40
+ threadId: string | null;
41
+ query: string;
42
+ from: string;
43
+ since: string;
44
+ until: string;
45
+ before: string;
46
+ unreadOnly: boolean;
47
+ limit: number;
48
+ }
49
+
50
+ export const DEFAULT_LIMIT = 20;
51
+ export const MAX_LIMIT = 50;
52
+ const SEEN_MAX = 500;
53
+ const SUBJECT_MAX = 255;
54
+ const ADDRESS_RE = /^[^@\s/]+@[^@\s/]+\.[^@\s/]+$/;
55
+
56
+ export interface MailService {
57
+ station: MailStation;
58
+ company: string;
59
+ product: string;
60
+ api: string;
61
+ apiCode: string;
62
+ }
63
+
64
+ export function refusalOf(s: MailService, status: number, detail: string): TrainError {
65
+ const refuse = (code: string, text: string, retryable = false): TrainError => new TrainError(`${s.station}_${code}`, text, { retryable });
66
+ if (status === 401) return refuse('signed_out', `${s.company} refused this mailbox's sign-in (${detail}); connect ${s.product} again from the page`);
67
+ if (status === 403) return refuse('forbidden', `${s.company} does not let Metro do that on this mailbox (${detail})`);
68
+ if (status === 404) return refuse('not_found', `${s.product} has no such message or conversation (${detail})`);
69
+ if (status === 413) return refuse('too_large', `${s.product} refused the size of that request (${detail})`);
70
+ if (status === 429) return refuse('throttled', `${s.company} asked Metro to slow down (${detail}); try again in a minute`, true);
71
+ return refuse(s.apiCode, `${s.api} answered ${String(status)} (${detail})`, status >= 500);
72
+ }
73
+
74
+ export const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
75
+
76
+ const encodeResource = (id: string): string => id.replace(/%/g, '%25').replace(/\//g, '%2F').replace(/\+/g, '%2B');
77
+
78
+ export const mailLine = (station: MailStation, accountId: string, threadId: string): string =>
79
+ `metro://${station}/${accountId}/${encodeResource(threadId)}`;
80
+
81
+ const mailUser = (station: MailStation, accountId: string, address: string): string =>
82
+ `metro://${station}/${accountId}/user/${address.toLowerCase()}`;
83
+
84
+ export function threadOfLine(station: MailStation, line: string): { accountId: string; threadId: string } | null {
85
+ const parsed = Line.parseMail(line, station);
86
+ if (parsed === null) return null;
87
+ try {
88
+ return { accountId: parsed.accountId, threadId: decodeURIComponent(parsed.resource) };
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ const isPrivateTo = (m: MailItem, self: string): boolean => m.to.length === 1 && m.to[0] === self && m.cc.length === 0;
95
+
96
+ export function mailEnvelope(station: MailStation, accountId: string, self: string, m: MailItem, attachments: AttachmentMeta[], verified: boolean): Record<string, unknown> {
97
+ const body = capText(m.text);
98
+ return {
99
+ id: mintId(),
100
+ ts: m.date ?? new Date().toISOString(),
101
+ station,
102
+ line: mailLine(station, accountId, m.threadId),
103
+ line_name: m.subject === '' ? '(no subject)' : m.subject,
104
+ from: mailUser(station, accountId, m.from === '' ? 'unknown' : m.from),
105
+ from_name: m.fromName || m.from,
106
+ from_display_name: m.fromName || undefined,
107
+ message_id: m.id,
108
+ text: m.subject === '' ? body : `Subject: ${m.subject}\n\n${body}`,
109
+ is_private: isPrivateTo(m, self),
110
+ sender_verified: verified,
111
+ payload: {
112
+ from: m.from,
113
+ to: m.to,
114
+ cc: m.cc,
115
+ subject: m.subject,
116
+ internetMessageId: m.internetMessageId,
117
+ ...(attachments.length === 0 ? {} : { attachments }),
118
+ },
119
+ };
120
+ }
121
+
122
+ const heading = (station: MailStation, accountId: string, m: MailItem): Record<string, unknown> => ({
123
+ message_id: m.id,
124
+ line: mailLine(station, accountId, m.threadId),
125
+ from: m.from,
126
+ from_name: m.fromName,
127
+ date: m.date,
128
+ title: m.subject,
129
+ });
130
+
131
+ export const mailSummary = (station: MailStation, accountId: string, m: MailItem): Record<string, unknown> => ({
132
+ ...heading(station, accountId, m),
133
+ text_preview: m.preview,
134
+ has_attachments: m.hasAttachments,
135
+ is_read: m.isRead,
136
+ });
137
+
138
+ export const mailFull = (station: MailStation, accountId: string, m: MailItem): Record<string, unknown> => ({
139
+ ...heading(station, accountId, m),
140
+ has_attachments: m.hasAttachments,
141
+ is_read: m.isRead,
142
+ to: m.to,
143
+ cc: m.cc,
144
+ text: capText(m.text),
145
+ });
146
+
147
+ export const isAddress = (target: string): boolean => target.includes('@');
148
+
149
+ export const isEmailAddress = (value: string): boolean => ADDRESS_RE.test(value);
150
+
151
+ export function checkAddress(station: MailStation, to: string): void {
152
+ if (!isEmailAddress(to)) throw new TrainError(`${station}_bad_address`, `not an email address: ${to}`, { retryable: false });
153
+ }
154
+
155
+ export function subjectOf(station: MailStation, subject: string, text: string): string {
156
+ const chosen = subject !== '' ? subject : (text.split('\n').find((l) => l.trim() !== '') ?? '').trim();
157
+ if (chosen === '') throw new TrainError(`${station}_subject_required`, 'a new email needs a subject or some text', { retryable: false });
158
+ return chosen.length > SUBJECT_MAX ? `${chosen.slice(0, SUBJECT_MAX - 3)}...` : chosen;
159
+ }
160
+
161
+ export const filesOf = (raw: unknown): OutgoingFile[] =>
162
+ (Array.isArray(raw) ? raw : [])
163
+ .map((a) => (a ?? {}) as Record<string, unknown>)
164
+ .filter((a) => typeof a.path === 'string' && a.path !== '')
165
+ .map((a) => ({
166
+ path: String(a.path),
167
+ mime: typeof a.mime === 'string' && a.mime !== '' ? a.mime : 'application/octet-stream',
168
+ name: typeof a.name === 'string' && a.name !== '' ? a.name : 'file',
169
+ }));
170
+
171
+ function isoOf(station: MailStation, raw: string, field: string): string {
172
+ if (raw === '') return '';
173
+ const at = Date.parse(raw);
174
+ if (Number.isNaN(at)) throw new TrainError(`${station}_bad_date`, `${field} must be a date, for example 2026-09-24 or 2026-09-24T10:00:00Z`, { retryable: false });
175
+ return new Date(at).toISOString();
176
+ }
177
+
178
+ export function readQueryOf(station: MailStation, args: Args): ReadQuery {
179
+ const line = str(args.line);
180
+ const thread = line === '' ? null : threadOfLine(station, line);
181
+ if (line !== '' && thread === null) throw new TrainError(`${station}_bad_line`, `not a valid ${station} line: ${line}`, { retryable: false });
182
+ return {
183
+ threadId: thread?.threadId ?? null,
184
+ query: str(args.query),
185
+ from: str(args.from).toLowerCase(),
186
+ since: isoOf(station, str(args.since), 'since'),
187
+ until: isoOf(station, str(args.until), 'until'),
188
+ before: str(args.before),
189
+ unreadOnly: args.unreadOnly === true,
190
+ limit: typeof args.limit === 'number' && args.limit > 0 ? Math.min(Math.floor(args.limit), MAX_LIMIT) : DEFAULT_LIMIT,
191
+ };
192
+ }
193
+
194
+ export const seenOf = (raw: unknown): string[] => (Array.isArray(raw) ? raw.filter((s): s is string => typeof s === 'string') : []);
195
+
196
+ export function noteSeen(seen: string[], messageId: string): void {
197
+ seen.push(messageId);
198
+ if (seen.length > SEEN_MAX) seen.splice(0, seen.length - SEEN_MAX);
199
+ }
200
+
201
+ export function pollForever(tag: string, once: () => Promise<unknown>, intervalMs: number): void {
202
+ const tick = (): void => {
203
+ once()
204
+ .catch((err: unknown) => {
205
+ process.stderr.write(`${tag} mail check failed: ${err instanceof Error ? err.message : String(err)}\n`);
206
+ })
207
+ .finally(() => {
208
+ setTimeout(tick, intervalMs);
209
+ });
210
+ };
211
+ tick();
212
+ }
@@ -0,0 +1,126 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+
3
+ export class SignInError extends Error {}
4
+
5
+ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
6
+
7
+ export type OAuthBody = Record<string, unknown>;
8
+
9
+ export interface Tokens {
10
+ accessToken: string;
11
+ refreshToken: string;
12
+ expiresAt: number;
13
+ }
14
+
15
+ export interface Pkce {
16
+ verifier: string;
17
+ challenge: string;
18
+ }
19
+
20
+ const RENEW_BEFORE_MS = 5 * 60_000;
21
+ const MAILBOX_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
22
+
23
+ const text = (v: unknown): string => (typeof v === 'string' ? v : '');
24
+
25
+ export const seconds = (v: unknown, fallback: number): number =>
26
+ typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : fallback;
27
+
28
+ export function envValue(name: string, fallback: string): string {
29
+ const value = (process.env[name] ?? '').trim();
30
+ return value === '' ? fallback : value;
31
+ }
32
+
33
+ export const envUrl = (name: string, fallback: string): string => envValue(name, fallback).replace(/\/+$/, '');
34
+
35
+ export function pkcePair(verifier = randomBytes(32).toString('base64url')): Pkce {
36
+ return { verifier, challenge: createHash('sha256').update(verifier).digest('base64url') };
37
+ }
38
+
39
+ export const newState = (): string => randomBytes(24).toString('base64url');
40
+
41
+ export function parseMailbox(value: unknown): string | null {
42
+ const mailbox = (typeof value === 'string' ? value.trim() : '').toLowerCase();
43
+ if (mailbox === '') return null;
44
+ if (!MAILBOX_RE.test(mailbox))
45
+ throw new SignInError('Type the whole address of the mailbox, like andy@company.com, or leave it blank.');
46
+ return mailbox;
47
+ }
48
+
49
+ export async function postForm(url: string, fields: Record<string, string>, fetchImpl: FetchLike, provider: string): Promise<{ status: number; body: OAuthBody }> {
50
+ let res: Response;
51
+ try {
52
+ res = await fetchImpl(url, {
53
+ method: 'POST',
54
+ headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
55
+ body: new URLSearchParams(fields).toString(),
56
+ });
57
+ } catch (err) {
58
+ throw new SignInError(`Metro could not reach ${provider}: ${err instanceof Error ? err.message : String(err)}`);
59
+ }
60
+ const raw: unknown = await res.json().catch(() => ({}));
61
+ return { status: res.status, body: typeof raw === 'object' && raw !== null ? (raw as OAuthBody) : {} };
62
+ }
63
+
64
+ export function tokensOf(body: OAuthBody, now: number, provider: string, previous?: string): Tokens {
65
+ const accessToken = text(body.access_token);
66
+ const refreshToken = text(body.refresh_token) || (previous ?? '');
67
+ if (accessToken === '' || refreshToken === '') throw new SignInError(`${provider} answered without the tokens Metro needs.`);
68
+ return { accessToken, refreshToken, expiresAt: now + seconds(body.expires_in, 3600) * 1000 };
69
+ }
70
+
71
+ export function tokenStateOf(saved: OAuthBody, seed: Partial<Tokens>): Tokens {
72
+ const fromFile = text(saved.refreshToken) !== '';
73
+ return {
74
+ refreshToken: fromFile ? text(saved.refreshToken) : text(seed.refreshToken),
75
+ accessToken: fromFile ? text(saved.accessToken) : text(seed.accessToken),
76
+ expiresAt: fromFile ? Number(saved.expiresAt) || 0 : Number(seed.expiresAt) || 0,
77
+ };
78
+ }
79
+
80
+ export class TokenKeeper {
81
+ private refreshing: Promise<string> | null = null;
82
+
83
+ constructor(
84
+ private readonly state: Tokens,
85
+ private readonly renewWith: (refreshToken: string) => Promise<Tokens>,
86
+ private readonly renewed: () => void,
87
+ ) {}
88
+
89
+ token(force = false): Promise<string> {
90
+ const fresh = this.state.accessToken !== '' && this.state.expiresAt - RENEW_BEFORE_MS > Date.now();
91
+ if (fresh && !force) return Promise.resolve(this.state.accessToken);
92
+ this.refreshing ??= this.renew().finally(() => {
93
+ this.refreshing = null;
94
+ });
95
+ return this.refreshing;
96
+ }
97
+
98
+ private async renew(): Promise<string> {
99
+ Object.assign(this.state, await this.renewWith(this.state.refreshToken));
100
+ this.renewed();
101
+ return this.state.accessToken;
102
+ }
103
+ }
104
+
105
+ export interface BrowserFlow<R> {
106
+ authorize: (challenge: string, state: string) => string;
107
+ redeem: (code: string, verifier: string) => Promise<R>;
108
+ }
109
+
110
+ export class BrowserSignIn<R> {
111
+ readonly state = newState();
112
+ readonly authorizeUrl: string;
113
+ private readonly pkce = pkcePair();
114
+ private used = false;
115
+
116
+ constructor(private readonly flow: BrowserFlow<R>) {
117
+ this.authorizeUrl = flow.authorize(this.pkce.challenge, this.state);
118
+ }
119
+
120
+ async finish(code: string, state: string): Promise<R> {
121
+ if (state !== this.state) throw new SignInError('This sign-in link belongs to another attempt. Start again from the Channels page.');
122
+ if (this.used) throw new SignInError('This sign-in was already used. Start again from the Channels page.');
123
+ this.used = true;
124
+ return this.flow.redeem(code, this.pkce.verifier);
125
+ }
126
+ }
@@ -30,6 +30,7 @@
30
30
  "@metro-labs/telegram-bot": "workspace:*",
31
31
  "@metro-labs/threema": "workspace:*",
32
32
  "@metro-labs/outlook": "workspace:*",
33
+ "@metro-labs/gmail": "workspace:*",
33
34
  "@metro-labs/webhook": "workspace:*",
34
35
  "@metro-labs/whatsapp": "workspace:*",
35
36
  "@metro-labs/xmtp": "workspace:*",
@@ -138,7 +138,7 @@ async function storeAccount(
138
138
 
139
139
  function asInput(body: unknown): Record<string, unknown> {
140
140
  const out: Record<string, unknown> = {};
141
- for (const key of ['apiId', 'apiHash', 'phone', 'mailbox'])
141
+ for (const key of ['apiId', 'apiHash', 'phone', 'mailbox', 'clientId', 'clientSecret'])
142
142
  out[key] = bodyField(body, key);
143
143
  return out;
144
144
  }
@@ -34,8 +34,8 @@ export const COMMON_TOOLS: ToolDef[] = [
34
34
  group: 'write',
35
35
  description:
36
36
  'Send a message (and/or media) to a Metro conversation. Args: line, text?, reply_to?, ' +
37
- 'subject?, attachments?. The station is derived from the line. Outlook: send to ' +
38
- 'metro://outlook/<account>/<email address> to start a new email (subject defaults to the ' +
37
+ 'subject?, attachments?. The station is derived from the line. Outlook and Gmail: send to ' +
38
+ 'metro://<outlook|gmail>/<account>/<email address> to start a new email (subject defaults to the ' +
39
39
  'first line of text); the result names the new thread line. Each attachment names EXACTLY ONE ' +
40
40
  'source, and the choice matters: `upload` (an `upload_id` from `create_upload`, the ' +
41
41
  'route for any real file on your own machine, confidential or not, up to 64 MiB); ' +
@@ -59,7 +59,7 @@ export const COMMON_TOOLS: ToolDef[] = [
59
59
  },
60
60
  subject: {
61
61
  type: 'string',
62
- description: 'Outlook only: the subject of a new email.',
62
+ description: 'Outlook and Gmail only: the subject of a new email.',
63
63
  },
64
64
  attachments: {
65
65
  type: 'array',
@@ -158,7 +158,9 @@ export const COMMON_TOOLS: ToolDef[] = [
158
158
  "(shapes differ per station), or the daemon's reason if the station does not support " +
159
159
  'reads. Outlook: `query` is a Microsoft search over all mail (with `from` folded into it; ' +
160
160
  'line, since, until and unread_only then narrow what the search found), and without ' +
161
- '`query` every filter is applied by Outlook itself, newest first.',
161
+ '`query` every filter is applied by Outlook itself, newest first. Gmail: `query` uses ' +
162
+ 'Gmail search syntax (from:, subject:, has:attachment, newer_than:7d...), and the other ' +
163
+ 'filters are added to it.',
162
164
  inputSchema: {
163
165
  type: 'object',
164
166
  properties: {
@@ -0,0 +1,102 @@
1
+ import { SignInError, parseMailbox } from '@metro-labs/core/stations/oauth';
2
+ import { StationAttachError } from './attach.js';
3
+ import type { AttachOutcome, DriverHooks, StartedAttach, StepInput } from './attach-driver.js';
4
+
5
+ const OUTLOOK_PROMPT =
6
+ 'Sign in with Microsoft in a new tab, with the mailbox this agent should read. This page updates on its own once you are done.';
7
+ const GMAIL_PROMPT =
8
+ 'Sign in with Google in a new tab, with the mailbox this agent should read. This page updates on its own once you are done.';
9
+ const DEVICE_PROMPT =
10
+ 'Open the Microsoft sign-in page, type this code, and sign in with the mailbox this agent should read.';
11
+ const OTHER_ATTEMPT = 'This sign-in link belongs to another attempt. Start again from the Channels page.';
12
+ const BROWSER_TTL_MS = 15 * 60_000;
13
+
14
+ interface BrowserLogin {
15
+ state: string;
16
+ authorizeUrl: string;
17
+ finish: (code: string, state: string) => Promise<AttachOutcome>;
18
+ }
19
+
20
+ interface BrowserAttach {
21
+ provider: string;
22
+ prompt: string;
23
+ failure: (body: Record<string, unknown>) => string;
24
+ device?: () => Promise<void>;
25
+ cancel?: () => Promise<void>;
26
+ }
27
+
28
+ const text = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
29
+
30
+ const said = (err: unknown, fallback: string): string => (err instanceof SignInError && err.message !== '' ? err.message : fallback);
31
+
32
+ function refused(err: unknown, provider: string): StationAttachError {
33
+ return new StationAttachError(said(err, `${provider} refused to start the sign-in`), 400);
34
+ }
35
+
36
+ function browserAttach(login: BrowserLogin, hooks: DriverHooks, how: BrowserAttach): StartedAttach {
37
+ const finish = async (input: StepInput): Promise<void> => {
38
+ const state = text(input.state);
39
+ if (state !== login.state) throw new StationAttachError(OTHER_ATTEMPT, 400);
40
+ const error = text(input.error);
41
+ if (error !== '') {
42
+ hooks.fail(how.failure({ error, error_description: text(input.errorDescription) }));
43
+ return;
44
+ }
45
+ const code = text(input.code);
46
+ if (code === '') throw new StationAttachError(`${how.provider} did not send a sign-in code back`, 400);
47
+ try {
48
+ hooks.done(await login.finish(code, state));
49
+ } catch (err) {
50
+ hooks.fail(said(err, `Metro could not finish the ${how.provider} sign-in.`));
51
+ }
52
+ };
53
+ const device = how.device;
54
+ return {
55
+ prompt: { step: 'browser', prompt: how.prompt, authorizeUrl: login.authorizeUrl },
56
+ expiresAt: Date.now() + BROWSER_TTL_MS,
57
+ driver: {
58
+ cancel: () => how.cancel?.() ?? Promise.resolve(),
59
+ submit: (input) => (input.mode === 'device' && device !== undefined ? device() : finish(input)),
60
+ },
61
+ };
62
+ }
63
+
64
+ export async function startOutlook(input: Record<string, unknown>, hooks: DriverHooks): Promise<StartedAttach> {
65
+ const { OutlookBrowserLogin, OutlookLogin: DeviceLogin, failureOf } = await import('@metro-labs/outlook/login');
66
+ const { browser, mailbox } = (() => {
67
+ try {
68
+ const wanted = parseMailbox(input.mailbox);
69
+ return { browser: new OutlookBrowserLogin({ mailbox: wanted }), mailbox: wanted };
70
+ } catch (err) {
71
+ throw refused(err, 'Microsoft');
72
+ }
73
+ })();
74
+ let device: InstanceType<typeof DeviceLogin> | null = null;
75
+ const toDevice = async (): Promise<void> => {
76
+ if (device !== null) return;
77
+ const login = new DeviceLogin({ onDone: hooks.done, onFailed: hooks.fail }, { mailbox });
78
+ const code = await login.start().catch((err: unknown) => {
79
+ throw refused(err, 'Microsoft');
80
+ });
81
+ device = login;
82
+ hooks.prompt({ step: 'device', prompt: DEVICE_PROMPT, userCode: code.userCode, verificationUri: code.verificationUri });
83
+ };
84
+ return browserAttach(browser, hooks, {
85
+ provider: 'Microsoft',
86
+ prompt: OUTLOOK_PROMPT,
87
+ failure: (body) => failureOf(body),
88
+ device: toDevice,
89
+ cancel: () => device?.cancel() ?? Promise.resolve(),
90
+ });
91
+ }
92
+
93
+ export async function startGmail(input: Record<string, unknown>, hooks: DriverHooks): Promise<StartedAttach> {
94
+ const { GmailBrowserLogin, failureOf } = await import('@metro-labs/gmail/login');
95
+ let login;
96
+ try {
97
+ login = new GmailBrowserLogin({ clientId: input.clientId, clientSecret: input.clientSecret, mailbox: input.mailbox });
98
+ } catch (err) {
99
+ throw refused(err, 'Google');
100
+ }
101
+ return browserAttach(login, hooks, { provider: 'Google', prompt: GMAIL_PROMPT, failure: failureOf });
102
+ }
@@ -1,8 +1,8 @@
1
1
  import { StationAttachError } from './attach.js';
2
2
  import type { DriverHooks, StartedAttach } from './attach-driver.js';
3
- import { startOutlook } from './attach-outlook.js';
3
+ import { startGmail, startOutlook } from './attach-browser.js';
4
4
 
5
- export const INTERACTIVE_STATIONS = ['telegram', 'whatsapp', 'outlook'] as const;
5
+ export const INTERACTIVE_STATIONS = ['telegram', 'whatsapp', 'outlook', 'gmail'] as const;
6
6
 
7
7
  export type InteractiveStation = (typeof INTERACTIVE_STATIONS)[number];
8
8
 
@@ -128,6 +128,7 @@ export async function startInteractiveAttach(
128
128
  hooks: DriverHooks,
129
129
  ): Promise<StartedAttach> {
130
130
  if (station === 'outlook') return startOutlook(input, hooks);
131
+ if (station === 'gmail') return startGmail(input, hooks);
131
132
  return station === 'telegram'
132
133
  ? startTelegramUser(input, hooks)
133
134
  : startWhatsapp(input, hooks);
@@ -91,6 +91,11 @@ const STATION_TARGETS: Record<StationName, StationTarget> = {
91
91
  fileEnv: 'OUTLOOK_ACCOUNTS_FILE',
92
92
  trainImport: '@metro-labs/outlook/train',
93
93
  },
94
+ gmail: {
95
+ file: 'gmail-accounts.json',
96
+ fileEnv: 'GMAIL_ACCOUNTS_FILE',
97
+ trainImport: '@metro-labs/gmail/train',
98
+ },
94
99
  webhook: {
95
100
  file: 'webhook-accounts.json',
96
101
  fileEnv: 'WEBHOOK_ACCOUNTS_FILE',
@@ -8,6 +8,7 @@ import { discordBotStation } from '@metro-labs/discord-bot';
8
8
  import { whatsappStation } from '@metro-labs/whatsapp';
9
9
  import { threemaStation } from '@metro-labs/threema';
10
10
  import { outlookStation } from '@metro-labs/outlook';
11
+ import { gmailStation } from '@metro-labs/gmail';
11
12
  import { webhookStation } from '@metro-labs/webhook';
12
13
 
13
14
  export const STATIONS: readonly Station[] = [
@@ -18,6 +19,7 @@ export const STATIONS: readonly Station[] = [
18
19
  whatsappStation,
19
20
  threemaStation,
20
21
  outlookStation,
22
+ gmailStation,
21
23
  webhookStation,
22
24
  ];
23
25
 
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@metro-labs/gmail",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/station.ts",
8
+ "./train": "./src/index.ts",
9
+ "./login": "./src/login.ts"
10
+ },
11
+ "scripts": {
12
+ "build": "tsc",
13
+ "test": "tsc --noEmit && bun test test/"
14
+ },
15
+ "dependencies": {
16
+ "@metro-labs/core": "workspace:*"
17
+ },
18
+ "devDependencies": {
19
+ "@stage-labs/config": "0.1.0-beta.2",
20
+ "eslint": "^10.3.0",
21
+ "knip": "^6.15.0",
22
+ "madge": "^8.0.0",
23
+ "typescript": "^5"
24
+ }
25
+ }