@mcinteerj/openclaw-gmail 1.2.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,42 @@
1
+ export function isGmailThreadId(id: string): boolean {
2
+ // Gmail thread IDs are hex strings, variable length (often ~16 chars)
3
+ return /^[0-9a-fA-F]{16,}$/.test(id) && !id.includes("@");
4
+ }
5
+
6
+ export function isEmail(id: string): boolean {
7
+ // Simple email validation
8
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(id);
9
+ }
10
+
11
+ export function normalizeGmailTarget(raw: string): string | null {
12
+ const trimmed = raw?.trim();
13
+ if (!trimmed) return null;
14
+
15
+ if (isEmail(trimmed)) {
16
+ return trimmed.toLowerCase();
17
+ }
18
+
19
+ if (isGmailThreadId(trimmed)) {
20
+ return trimmed.toLowerCase();
21
+ }
22
+
23
+ return null;
24
+ }
25
+
26
+ export function isAllowed(senderId: string, allowList: string[]): boolean {
27
+ if (allowList.length === 0) return false;
28
+ if (allowList.includes("*")) return true;
29
+
30
+ const normalizedSender = senderId.toLowerCase();
31
+ return allowList.some((entry) => {
32
+ const normalized = entry.toLowerCase().trim();
33
+ if (!normalized) return false;
34
+ // Exact match
35
+ if (normalizedSender === normalized) return true;
36
+ // Domain wildcard match (e.g., "@gmail.com")
37
+ if (normalized.startsWith("@") && normalizedSender.endsWith(normalized)) {
38
+ return true;
39
+ }
40
+ return false;
41
+ });
42
+ }
@@ -0,0 +1,214 @@
1
+ import type { OpenClawConfig, ChannelOnboardingAdapter } from "openclaw/plugin-sdk";
2
+ import { promptAccountId } from "openclaw/plugin-sdk";
3
+ import { exec } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { listGmailAccountIds, resolveDefaultGmailAccountId } from "./accounts.js";
6
+
7
+ const execAsync = promisify(exec);
8
+ const channel = "gmail" as const;
9
+
10
+ const MIN_GOG_VERSION = "1.2.0";
11
+
12
+ async function checkGogInstalled(): Promise<boolean> {
13
+ try {
14
+ await execAsync("command -v gog");
15
+ return true;
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ async function getGogVersion(): Promise<string | null> {
22
+ try {
23
+ const { stdout } = await execAsync("gog --version");
24
+ const match = stdout.match(/gog version (\d+\.\d+\.\d+)/);
25
+ return match ? match[1] : null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ function isVersionAtLeast(current: string, min: string): boolean {
32
+ const currentParts = current.split(".").map(Number);
33
+ const minParts = min.split(".").map(Number);
34
+ for (let i = 0; i < 3; i++) {
35
+ if (currentParts[i] > minParts[i]) return true;
36
+ if (currentParts[i] < minParts[i]) return false;
37
+ }
38
+ return true;
39
+ }
40
+
41
+ async function checkGogAuth(email: string): Promise<boolean> {
42
+ try {
43
+ const { stdout } = await execAsync("gog auth list --plain");
44
+ return stdout.includes(email);
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ async function authorizeGog(email: string): Promise<void> {
51
+ const { spawn } = await import("node:child_process");
52
+ return new Promise((resolve, reject) => {
53
+ const child = spawn("gog", ["auth", "add", email], { stdio: "inherit" });
54
+ child.on("close", (code) => {
55
+ if (code === 0) resolve();
56
+ else reject(new Error(`gog auth failed with code ${code}`));
57
+ });
58
+ });
59
+ }
60
+
61
+ async function fetchGmailName(email: string): Promise<string | undefined> {
62
+ try {
63
+ const { stdout } = await execAsync(`gog gmail settings sendas list --account ${email} --json`);
64
+ const data = JSON.parse(stdout);
65
+ const primary = data.sendAs?.find((s: any) => s.isPrimary) || data.sendAs?.[0];
66
+ return primary?.displayName;
67
+ } catch {
68
+ return undefined;
69
+ }
70
+ }
71
+
72
+ export const gmailOnboardingAdapter: ChannelOnboardingAdapter = {
73
+ channel,
74
+ getStatus: async ({ cfg }: { cfg: OpenClawConfig }) => {
75
+ const ids = listGmailAccountIds(cfg);
76
+ const configured = ids.length > 0;
77
+ return {
78
+ channel,
79
+ configured,
80
+ statusLines: [`Gmail: ${configured ? `${ids.length} accounts` : "not configured"}`],
81
+ selectionHint: "Polling via gog CLI",
82
+ quickstartScore: configured ? 1 : 5,
83
+ };
84
+ },
85
+ configure: async ({
86
+ cfg,
87
+ prompter,
88
+ accountOverrides,
89
+ shouldPromptAccountIds,
90
+ }: {
91
+ cfg: OpenClawConfig;
92
+ prompter: {
93
+ text: (opts: { message: string; validate?: (val?: string) => string | undefined; initialValue?: string }) => Promise<string>;
94
+ confirm: (opts: { message: string; initialValue?: boolean }) => Promise<boolean>;
95
+ note: (message: string, title?: string) => Promise<void>;
96
+ };
97
+ accountOverrides: Record<string, string>;
98
+ shouldPromptAccountIds: boolean;
99
+ }) => {
100
+ if (!(await checkGogInstalled())) {
101
+ await prompter.note(
102
+ "The `gog` CLI is required for the Gmail extension.\nPlease install it and ensure it is in your PATH.",
103
+ "Missing Dependency"
104
+ );
105
+ throw new Error("gog CLI not found");
106
+ }
107
+
108
+ const version = await getGogVersion();
109
+ if (version && !isVersionAtLeast(version, MIN_GOG_VERSION)) {
110
+ await prompter.note(
111
+ `Your gog version (${version}) is below the recommended ${MIN_GOG_VERSION}.\nSome features may not work correctly.`,
112
+ "Version Warning"
113
+ );
114
+ }
115
+
116
+ const existingIds = listGmailAccountIds(cfg);
117
+ const gmailOverride = accountOverrides.gmail?.trim();
118
+ const defaultAccountId = resolveDefaultGmailAccountId(cfg);
119
+ let accountId = gmailOverride || defaultAccountId;
120
+
121
+ if (shouldPromptAccountIds && !gmailOverride && existingIds.length > 0) {
122
+ accountId = await promptAccountId({
123
+ cfg,
124
+ prompter,
125
+ label: "Gmail",
126
+ currentId: accountId,
127
+ listAccountIds: listGmailAccountIds,
128
+ defaultAccountId,
129
+ });
130
+ }
131
+
132
+ let email = accountId.includes("@") ? accountId : undefined;
133
+ if (!email) {
134
+ email = await prompter.text({
135
+ message: "Gmail address",
136
+ validate: (val: string | undefined) => (val?.includes("@") ? undefined : "Valid email required"),
137
+ });
138
+ }
139
+
140
+ if (!email) throw new Error("Email required");
141
+
142
+ const isAuthed = await checkGogAuth(email);
143
+ if (!isAuthed) {
144
+ await prompter.note(
145
+ `Gog CLI is not authorized for ${email}. We need to authorize it now.`,
146
+ "Authorization"
147
+ );
148
+ const doAuth = await prompter.confirm({
149
+ message: "Authorize gog now?",
150
+ initialValue: true,
151
+ });
152
+ if (doAuth) {
153
+ await authorizeGog(email);
154
+ } else {
155
+ await prompter.note("Skipping auth. You must run `gog auth add " + email + "` manually.", "Warning");
156
+ }
157
+ }
158
+
159
+ const allowFromRaw = await prompter.text({
160
+ message: "Allow emails from (comma separated, * for all)",
161
+ initialValue: "",
162
+ });
163
+ const allowFrom = allowFromRaw.split(",").map((s: string) => s.trim()).filter(Boolean);
164
+
165
+ const pollIntervalSecsRaw = await prompter.text({
166
+ message: "Polling interval (seconds)",
167
+ initialValue: "60",
168
+ validate: (val: string | undefined) => {
169
+ const n = parseInt(val || "", 10);
170
+ return isNaN(n) || n < 1 ? "Positive integer required" : undefined;
171
+ }
172
+ });
173
+ const pollIntervalMs = parseInt(pollIntervalSecsRaw, 10) * 1000;
174
+
175
+ const name = await fetchGmailName(email);
176
+
177
+ const accountConfig = {
178
+ enabled: true,
179
+ email,
180
+ name,
181
+ allowFrom,
182
+ pollIntervalMs,
183
+ };
184
+
185
+ const gmailConfig = cfg.channels?.gmail || {};
186
+ const accounts = gmailConfig.accounts || {};
187
+
188
+ const next = {
189
+ ...cfg,
190
+ channels: {
191
+ ...cfg.channels,
192
+ gmail: {
193
+ dmPolicy: "allowlist",
194
+ archiveOnReply: true,
195
+ ...gmailConfig,
196
+ enabled: true,
197
+ accounts: {
198
+ ...accounts,
199
+ [email]: accountConfig,
200
+ },
201
+ },
202
+ },
203
+ };
204
+
205
+ return { cfg: next, accountId: email };
206
+ },
207
+ disable: (cfg: OpenClawConfig) => ({
208
+ ...cfg,
209
+ channels: {
210
+ ...cfg.channels,
211
+ gmail: { ...cfg.channels?.gmail, enabled: false },
212
+ },
213
+ }),
214
+ };
@@ -0,0 +1,159 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { isEmailAllowed, parseEmailAddresses } from "./outbound-check.js";
3
+
4
+ describe("parseEmailAddresses", () => {
5
+ describe("simple emails", () => {
6
+ it("parses plain email address", () => {
7
+ const result = parseEmailAddresses("user@example.com");
8
+ expect(result).toEqual([{ email: "user@example.com" }]);
9
+ });
10
+
11
+ it("lowercases email addresses", () => {
12
+ const result = parseEmailAddresses("User@Example.COM");
13
+ expect(result).toEqual([{ email: "user@example.com" }]);
14
+ });
15
+ });
16
+
17
+ describe("named addresses", () => {
18
+ it("parses Name <email> format", () => {
19
+ const result = parseEmailAddresses("John Doe <john@example.com>");
20
+ expect(result).toEqual([{ name: "John Doe", email: "john@example.com" }]);
21
+ });
22
+
23
+ it("parses quoted name with special chars", () => {
24
+ const result = parseEmailAddresses('"Last, First" <user@example.com>');
25
+ expect(result).toEqual([{ name: "Last, First", email: "user@example.com" }]);
26
+ });
27
+
28
+ it("handles name with quotes", () => {
29
+ const result = parseEmailAddresses('"John \\"Johnny\\" Doe" <john@example.com>');
30
+ expect(result[0].email).toBe("john@example.com");
31
+ });
32
+ });
33
+
34
+ describe("multiple addresses", () => {
35
+ it("parses comma-separated addresses", () => {
36
+ const result = parseEmailAddresses("a@example.com, b@example.com");
37
+ expect(result).toHaveLength(2);
38
+ expect(result[0].email).toBe("a@example.com");
39
+ expect(result[1].email).toBe("b@example.com");
40
+ });
41
+
42
+ it("parses mixed formats", () => {
43
+ const result = parseEmailAddresses("John <john@example.com>, jane@example.com");
44
+ expect(result).toHaveLength(2);
45
+ expect(result[0]).toEqual({ name: "John", email: "john@example.com" });
46
+ expect(result[1]).toEqual({ email: "jane@example.com" });
47
+ });
48
+
49
+ it("handles quoted names with commas among multiple addresses", () => {
50
+ const result = parseEmailAddresses('"Last, First" <a@example.com>, b@example.com');
51
+ expect(result).toHaveLength(2);
52
+ expect(result[0].name).toBe("Last, First");
53
+ expect(result[0].email).toBe("a@example.com");
54
+ expect(result[1].email).toBe("b@example.com");
55
+ });
56
+ });
57
+
58
+ describe("edge cases", () => {
59
+ it("returns empty array for empty string", () => {
60
+ expect(parseEmailAddresses("")).toEqual([]);
61
+ });
62
+
63
+ it("returns empty array for null/undefined", () => {
64
+ expect(parseEmailAddresses(null as any)).toEqual([]);
65
+ expect(parseEmailAddresses(undefined as any)).toEqual([]);
66
+ });
67
+
68
+ it("ignores malformed entries without @", () => {
69
+ const result = parseEmailAddresses("not-an-email, valid@example.com");
70
+ expect(result).toHaveLength(1);
71
+ expect(result[0].email).toBe("valid@example.com");
72
+ });
73
+
74
+ it("handles extra whitespace", () => {
75
+ const result = parseEmailAddresses(" user@example.com ");
76
+ expect(result).toEqual([{ email: "user@example.com" }]);
77
+ });
78
+ });
79
+ });
80
+
81
+ describe("isEmailAllowed", () => {
82
+ describe("empty allowlist", () => {
83
+ it("allows any email when allowlist is empty", () => {
84
+ expect(isEmailAllowed("anyone@example.com", [])).toBe(true);
85
+ });
86
+ });
87
+
88
+ describe("wildcard", () => {
89
+ it("allows any email with * wildcard", () => {
90
+ expect(isEmailAllowed("anyone@anywhere.com", ["*"])).toBe(true);
91
+ });
92
+
93
+ it("allows any email with * among other entries", () => {
94
+ expect(isEmailAllowed("random@test.com", ["specific@example.com", "*"])).toBe(true);
95
+ });
96
+ });
97
+
98
+ describe("exact match", () => {
99
+ it("allows exact email match", () => {
100
+ expect(isEmailAllowed("user@example.com", ["user@example.com"])).toBe(true);
101
+ });
102
+
103
+ it("matches case-insensitively", () => {
104
+ expect(isEmailAllowed("User@Example.COM", ["user@example.com"])).toBe(true);
105
+ });
106
+
107
+ it("rejects non-matching email", () => {
108
+ expect(isEmailAllowed("other@example.com", ["user@example.com"])).toBe(false);
109
+ });
110
+ });
111
+
112
+ describe("domain wildcard", () => {
113
+ it("allows email matching domain wildcard", () => {
114
+ expect(isEmailAllowed("anyone@company.com", ["@company.com"])).toBe(true);
115
+ });
116
+
117
+ it("matches domain case-insensitively", () => {
118
+ expect(isEmailAllowed("user@COMPANY.COM", ["@company.com"])).toBe(true);
119
+ });
120
+
121
+ it("rejects email from different domain", () => {
122
+ expect(isEmailAllowed("user@other.com", ["@company.com"])).toBe(false);
123
+ });
124
+
125
+ it("does not match partial domain names", () => {
126
+ // @company.com should not match @notcompany.com
127
+ expect(isEmailAllowed("user@notcompany.com", ["@company.com"])).toBe(false);
128
+ });
129
+
130
+ it("matches subdomain when specified", () => {
131
+ expect(isEmailAllowed("user@sub.company.com", ["@sub.company.com"])).toBe(true);
132
+ });
133
+ });
134
+
135
+ describe("multiple entries", () => {
136
+ it("allows if any entry matches", () => {
137
+ const allowList = ["admin@example.com", "@trusted.com", "vip@special.org"];
138
+
139
+ expect(isEmailAllowed("admin@example.com", allowList)).toBe(true);
140
+ expect(isEmailAllowed("anyone@trusted.com", allowList)).toBe(true);
141
+ expect(isEmailAllowed("vip@special.org", allowList)).toBe(true);
142
+ expect(isEmailAllowed("random@other.com", allowList)).toBe(false);
143
+ });
144
+ });
145
+
146
+ describe("edge cases", () => {
147
+ it("handles whitespace in allowlist entries", () => {
148
+ expect(isEmailAllowed("user@example.com", [" user@example.com "])).toBe(true);
149
+ });
150
+
151
+ it("ignores empty entries", () => {
152
+ expect(isEmailAllowed("user@example.com", ["", " ", "user@example.com"])).toBe(true);
153
+ });
154
+
155
+ it("handles empty email gracefully", () => {
156
+ expect(isEmailAllowed("", ["user@example.com"])).toBe(false);
157
+ });
158
+ });
159
+ });
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Thread recipient validation for Gmail outbound.
3
+ *
4
+ * Validates that thread reply recipients are permitted by allowOutboundTo.
5
+ */
6
+
7
+ import { spawn } from "node:child_process";
8
+
9
+ export interface ThreadParticipant {
10
+ email: string;
11
+ name?: string;
12
+ }
13
+
14
+ export interface ThreadData {
15
+ participants: ThreadParticipant[];
16
+ originalSender: string | null;
17
+ }
18
+
19
+ /**
20
+ * Fetch thread data (participants and original sender) in a single call.
21
+ */
22
+ export async function fetchThreadData(
23
+ threadId: string,
24
+ accountEmail: string
25
+ ): Promise<ThreadData> {
26
+ return new Promise((resolve, reject) => {
27
+ const args = ["gmail", "thread", "get", threadId, "--json", "--account", accountEmail];
28
+ const proc = spawn("gog", args, { stdio: "pipe" });
29
+ let stdout = "";
30
+ let stderr = "";
31
+ let settled = false;
32
+
33
+ const timeout = setTimeout(() => {
34
+ if (!settled) {
35
+ settled = true;
36
+ proc.kill();
37
+ reject(new Error("Timeout fetching thread data"));
38
+ }
39
+ }, 10000);
40
+
41
+ const cleanup = () => {
42
+ clearTimeout(timeout);
43
+ };
44
+
45
+ proc.stdout.on("data", (d) => (stdout += d.toString()));
46
+ proc.stderr.on("data", (d) => (stderr += d.toString()));
47
+
48
+ proc.on("close", (code) => {
49
+ if (settled) return;
50
+ settled = true;
51
+ cleanup();
52
+
53
+ if (code !== 0) {
54
+ reject(new Error(`Failed to fetch thread: ${stderr}`));
55
+ return;
56
+ }
57
+
58
+ try {
59
+ const data = JSON.parse(stdout);
60
+ const messages = data.thread?.messages || [];
61
+ const participants = new Map<string, ThreadParticipant>();
62
+ let originalSender: string | null = null;
63
+
64
+ for (let i = 0; i < messages.length; i++) {
65
+ const msg = messages[i];
66
+ const headers = msg.payload?.headers || [];
67
+
68
+ for (const header of headers) {
69
+ const name = header.name.toLowerCase();
70
+ if (["from", "to", "cc"].includes(name)) {
71
+ const addresses = parseEmailAddresses(header.value);
72
+ for (const addr of addresses) {
73
+ if (addr.email.toLowerCase() !== accountEmail.toLowerCase()) {
74
+ participants.set(addr.email.toLowerCase(), addr);
75
+ }
76
+
77
+ // Capture original sender from first message's From header
78
+ if (i === 0 && name === "from" && !originalSender) {
79
+ originalSender = addr.email;
80
+ }
81
+ }
82
+ }
83
+ }
84
+ }
85
+
86
+ resolve({
87
+ participants: Array.from(participants.values()),
88
+ originalSender,
89
+ });
90
+ } catch (e) {
91
+ reject(new Error(`Failed to parse thread: ${e}`));
92
+ }
93
+ });
94
+
95
+ proc.on("error", (e) => {
96
+ if (settled) return;
97
+ settled = true;
98
+ cleanup();
99
+ reject(new Error(`Failed to spawn gog: ${e.message}`));
100
+ });
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Parse email addresses from a header value like "Name <email>, Other <email2>"
106
+ *
107
+ * Handles:
108
+ * - Simple: email@example.com
109
+ * - Named: Name <email@example.com>
110
+ * - Quoted names with commas: "Last, First" <email@example.com>
111
+ * - Multiple addresses separated by commas
112
+ */
113
+ export function parseEmailAddresses(value: string): ThreadParticipant[] {
114
+ const results: ThreadParticipant[] = [];
115
+ if (!value || typeof value !== "string") return results;
116
+
117
+ // Split by comma, but respect quoted strings
118
+ // This regex splits on commas that are NOT inside quotes
119
+ const parts = value.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/);
120
+
121
+ for (const part of parts) {
122
+ const trimmed = part.trim();
123
+ if (!trimmed) continue;
124
+
125
+ // Try to match "Name" <email> or Name <email>
126
+ const match = trimmed.match(/^(?:"?([^"<]*)"?\s*)?<([^>]+)>$/);
127
+
128
+ if (match) {
129
+ const name = match[1]?.trim().replace(/^"|"$/g, "");
130
+ const email = match[2].trim().toLowerCase();
131
+ if (email.includes("@")) {
132
+ results.push({ name: name || undefined, email });
133
+ }
134
+ } else if (trimmed.includes("@")) {
135
+ // Plain email address
136
+ results.push({ email: trimmed.toLowerCase() });
137
+ }
138
+ }
139
+
140
+ return results;
141
+ }
142
+
143
+ /**
144
+ * Check if an email is allowed by the allowlist.
145
+ *
146
+ * Rules:
147
+ * - Empty list = no restriction (returns true)
148
+ * - "*" in list = allow all
149
+ * - Exact match (case-insensitive)
150
+ * - Domain wildcard: "@company.com" matches any @company.com address
151
+ */
152
+ export function isEmailAllowed(email: string, allowList: string[]): boolean {
153
+ // Empty list = no restriction
154
+ if (allowList.length === 0) return true;
155
+ if (allowList.includes("*")) return true;
156
+
157
+ if (!email) return false;
158
+ const normalized = email.toLowerCase();
159
+
160
+ return allowList.some((entry) => {
161
+ const e = entry.toLowerCase().trim();
162
+ if (!e) return false;
163
+ if (normalized === e) return true;
164
+ // Domain wildcard: must start with @ and email must end with it
165
+ if (e.startsWith("@") && normalized.endsWith(e)) return true;
166
+ return false;
167
+ });
168
+ }
169
+
170
+ export interface ValidationResult {
171
+ ok: boolean;
172
+ blocked?: string[];
173
+ reason?: string;
174
+ }
175
+
176
+ /**
177
+ * Validate thread recipients against policy.
178
+ *
179
+ * Policies:
180
+ * - "open": Allow replies to anyone (default, backwards compatible)
181
+ * - "allowlist": All recipients must be in allowOutboundTo
182
+ * - "sender-only": Only reply if original sender is allowed (ignore CC'd parties)
183
+ */
184
+ export async function validateThreadReply(
185
+ threadId: string,
186
+ accountEmail: string,
187
+ allowOutboundTo: string[],
188
+ policy: "open" | "allowlist" | "sender-only"
189
+ ): Promise<ValidationResult> {
190
+ if (policy === "open") {
191
+ return { ok: true };
192
+ }
193
+
194
+ let threadData: ThreadData;
195
+ try {
196
+ threadData = await fetchThreadData(threadId, accountEmail);
197
+ } catch (err) {
198
+ console.error(`[gmail] Failed to fetch thread data for validation: ${err}`);
199
+ return { ok: false, reason: `Could not fetch thread data: ${err}` };
200
+ }
201
+
202
+ if (policy === "sender-only") {
203
+ if (!threadData.originalSender) {
204
+ console.error(`[gmail] Could not determine original sender for thread ${threadId}`);
205
+ return { ok: false, reason: "Could not determine thread sender" };
206
+ }
207
+
208
+ if (!isEmailAllowed(threadData.originalSender, allowOutboundTo)) {
209
+ return {
210
+ ok: false,
211
+ blocked: [threadData.originalSender],
212
+ reason: "Thread sender not in allowOutboundTo"
213
+ };
214
+ }
215
+
216
+ return { ok: true };
217
+ }
218
+
219
+ // policy === "allowlist"
220
+ const blocked: string[] = [];
221
+
222
+ for (const p of threadData.participants) {
223
+ if (!isEmailAllowed(p.email, allowOutboundTo)) {
224
+ blocked.push(p.email);
225
+ }
226
+ }
227
+
228
+ if (blocked.length > 0) {
229
+ return { ok: false, blocked, reason: "Recipients not in allowOutboundTo" };
230
+ }
231
+
232
+ return { ok: true };
233
+ }