diff --git a/package-lock.json b/package-lock.json
index e3d4dd39..52d6109f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -35,6 +35,7 @@
"llmist": "^15.19.0",
"marklassian": "^1.1.0",
"nodemailer": "^8.0.1",
+ "open": "^11.0.0",
"pg": "^8.18.0",
"trello.js": "^1.2.8",
"zangief": "latest",
@@ -4742,6 +4743,21 @@
"uuid": "11.1.0"
}
},
+ "node_modules/bundle-name": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
+ "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==",
+ "license": "MIT",
+ "dependencies": {
+ "run-applescript": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/cac": {
"version": "6.7.14",
"dev": true,
@@ -5350,6 +5366,46 @@
"node": ">=6"
}
},
+ "node_modules/default-browser": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz",
+ "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==",
+ "license": "MIT",
+ "dependencies": {
+ "bundle-name": "^4.1.0",
+ "default-browser-id": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/default-browser-id": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz",
+ "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/define-lazy-prop": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
+ "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/delayed-stream": {
"version": "1.0.0",
"license": "MIT",
@@ -7206,6 +7262,51 @@
"node": ">=8"
}
},
+ "node_modules/is-in-ssh": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz",
+ "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-inside-container": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
+ "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
+ "license": "MIT",
+ "dependencies": {
+ "is-docker": "^3.0.0"
+ },
+ "bin": {
+ "is-inside-container": "cli.js"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-inside-container/node_modules/is-docker": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
+ "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
+ "license": "MIT",
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-network-error": {
"version": "1.3.0",
"license": "MIT",
@@ -8321,6 +8422,26 @@
"wrappy": "1"
}
},
+ "node_modules/open": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz",
+ "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==",
+ "license": "MIT",
+ "dependencies": {
+ "default-browser": "^5.4.0",
+ "define-lazy-prop": "^3.0.0",
+ "is-in-ssh": "^1.0.0",
+ "is-inside-container": "^1.0.0",
+ "powershell-utils": "^0.1.0",
+ "wsl-utils": "^0.3.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/openai": {
"version": "6.15.0",
"license": "Apache-2.0",
@@ -8705,6 +8826,18 @@
"node": ">=0.10.0"
}
},
+ "node_modules/powershell-utils": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
+ "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
@@ -9058,6 +9191,18 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/run-applescript": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
+ "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/safe-buffer": {
"version": "5.2.1",
"funding": [
@@ -10559,6 +10704,37 @@
}
}
},
+ "node_modules/wsl-utils": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz",
+ "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==",
+ "license": "MIT",
+ "dependencies": {
+ "is-wsl": "^3.1.0",
+ "powershell-utils": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/wsl-utils/node_modules/is-wsl": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz",
+ "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-inside-container": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
diff --git a/package.json b/package.json
index 5b28ca7e..0652bd50 100644
--- a/package.json
+++ b/package.json
@@ -37,7 +37,8 @@
"credentials:generate-key": "node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"",
"credentials:encrypt": "node --env-file=.env --import tsx tools/migrate-credentials-encrypt.ts",
"credentials:decrypt": "node --env-file=.env --import tsx tools/migrate-credentials-decrypt.ts",
- "credentials:rotate-key": "node --env-file=.env --import tsx tools/rotate-credential-key.ts"
+ "credentials:rotate-key": "node --env-file=.env --import tsx tools/rotate-credential-key.ts",
+ "tool:test-email": "npx tsx tools/test-email-integration.ts"
},
"keywords": [
"trello",
@@ -75,6 +76,7 @@
"llmist": "^15.19.0",
"marklassian": "^1.1.0",
"nodemailer": "^8.0.1",
+ "open": "^11.0.0",
"pg": "^8.18.0",
"trello.js": "^1.2.8",
"zangief": "latest",
diff --git a/src/agents/definitions/email-joke.yaml b/src/agents/definitions/email-joke.yaml
new file mode 100644
index 00000000..18ffb320
--- /dev/null
+++ b/src/agents/definitions/email-joke.yaml
@@ -0,0 +1,33 @@
+identity:
+ emoji: "\U0001F602"
+ label: Email Joke Responder
+ roleHint: Reads emails from a specific sender and responds with jokes
+ initialMessage: "**\U0001F602 Checking for emails to respond to with jokes**"
+
+capabilities:
+ canEditFiles: false
+ canCreatePR: false
+ canUpdateChecklists: false
+ isReadOnly: true
+ canAccessEmail: true
+
+tools:
+ sets: [email, session]
+ sdkTools: readOnly
+
+strategies:
+ contextPipeline: []
+ taskPromptBuilder: emailJoke
+ gadgetBuilder: emailJoke
+
+backend:
+ enableStopHooks: false
+ needsGitHubToken: false
+
+compaction: default
+
+trailingMessage: {}
+
+hint: >-
+ Search for emails, read them, and send funny responses. Mark processed emails
+ as seen to prevent re-processing.
diff --git a/src/agents/definitions/schema.ts b/src/agents/definitions/schema.ts
index 337b239e..4f8f2839 100644
--- a/src/agents/definitions/schema.ts
+++ b/src/agents/definitions/schema.ts
@@ -45,8 +45,15 @@ const StrategiesSchema = z.object({
'prConversation',
]),
),
- taskPromptBuilder: z.enum(['workItem', 'commentResponse', 'review', 'ci', 'prCommentResponse']),
- gadgetBuilder: z.enum(['workItem', 'review', 'prAgent']),
+ taskPromptBuilder: z.enum([
+ 'workItem',
+ 'commentResponse',
+ 'review',
+ 'ci',
+ 'prCommentResponse',
+ 'emailJoke',
+ ]),
+ gadgetBuilder: z.enum(['workItem', 'review', 'prAgent', 'emailJoke']),
gadgetBuilderOptions: GadgetBuilderOptionsSchema,
});
diff --git a/src/agents/definitions/strategies.ts b/src/agents/definitions/strategies.ts
index 2a92640e..d7aafcbb 100644
--- a/src/agents/definitions/strategies.ts
+++ b/src/agents/definitions/strategies.ts
@@ -1,6 +1,7 @@
import type { ContextInjection } from '../../backends/types.js';
import type { AgentCapabilities } from '../shared/capabilities.js';
import {
+ buildEmailJokeGadgets,
buildPRAgentGadgets,
buildReviewGadgets,
buildWorkItemGadgets,
@@ -56,7 +57,13 @@ export const GITHUB_CI_TOOLS = [
];
/** Email tools for agents that need email access */
-export const EMAIL_TOOLS = ['SendEmail', 'SearchEmails', 'ReadEmail', 'ReplyToEmail'];
+export const EMAIL_TOOLS = [
+ 'SendEmail',
+ 'SearchEmails',
+ 'ReadEmail',
+ 'ReplyToEmail',
+ 'MarkEmailAsSeen',
+];
export const SESSION_TOOL = 'Finish';
@@ -122,4 +129,5 @@ export const GADGET_BUILDER_REGISTRY: Record<
workItem: (caps) => buildWorkItemGadgets(caps),
review: () => buildReviewGadgets(),
prAgent: (_caps, options) => buildPRAgentGadgets(options),
+ emailJoke: () => buildEmailJokeGadgets(),
};
diff --git a/src/agents/prompts/index.ts b/src/agents/prompts/index.ts
index 6419832f..f997f131 100644
--- a/src/agents/prompts/index.ts
+++ b/src/agents/prompts/index.ts
@@ -148,6 +148,8 @@ export interface TaskPromptContext {
prBranch?: string;
commentBody?: string;
commentPath?: string;
+ // Email-joke agent fields
+ senderEmail?: string;
[key: string]: unknown;
}
diff --git a/src/agents/prompts/task-templates/emailJoke.eta b/src/agents/prompts/task-templates/emailJoke.eta
new file mode 100644
index 00000000..11bfb14d
--- /dev/null
+++ b/src/agents/prompts/task-templates/emailJoke.eta
@@ -0,0 +1,21 @@
+## Your Task
+
+1. Use **SearchEmails** to find unread emails<% if (it.senderEmail) { %> from: **<%= it.senderEmail %>**<% } %>
+ - Search criteria: `{ unseen: true<% if (it.senderEmail) { %>, from: "<%= it.senderEmail %>"<% } %> }`
+ - Limit results to prevent overwhelming responses
+
+2. For each email found:
+ - Use **ReadEmail** to read the full content
+ - Compose a friendly, funny response that relates to the email content
+ - Use **ReplyToEmail** to send your joke response
+ - Use **MarkEmailAsSeen** to mark the email as read
+
+3. Call **Finish** when you've processed all emails (or if none were found)
+
+<% if (it.senderEmail) { %>
+## Sender Filter
+Only respond to emails from: **<%= it.senderEmail %>**
+<% } else { %>
+## Note
+No sender filter configured. Processing all unread emails.
+<% } %>
diff --git a/src/agents/prompts/templates/email-joke.eta b/src/agents/prompts/templates/email-joke.eta
new file mode 100644
index 00000000..92e533a8
--- /dev/null
+++ b/src/agents/prompts/templates/email-joke.eta
@@ -0,0 +1,36 @@
+You are a friendly comedian bot who reads emails and responds with relevant, tasteful jokes.
+
+## Your Mission
+
+Search for unread emails from a specific sender, read their content, and respond with a friendly message that includes a relevant, funny joke.
+
+## Guidelines
+
+### Joke Quality
+- Keep jokes clean, professional, and appropriate for all audiences
+- Tailor the humor to the email content when possible
+- Be warm and friendly in your responses
+- If the email is serious or urgent, acknowledge it briefly before the joke
+- Vary your joke styles: puns, one-liners, observational humor, etc.
+
+### Response Format
+- Start with a brief, friendly acknowledgment of their email
+- Include your joke (clearly formatted)
+- Sign off as "Your Friendly Joke Bot 😂"
+
+### Email Processing
+- After successfully replying to an email, ALWAYS mark it as seen using MarkEmailAsSeen
+- This prevents re-processing the same email on subsequent runs
+- Process one email at a time: read → reply → mark as seen → move to next
+
+### Completion
+- When you've processed all matching emails (or found none), call Finish
+- If no emails are found, that's fine — just call Finish with a brief summary
+
+## Available Tools
+
+- **SearchEmails**: Find emails by sender, subject, date, etc.
+- **ReadEmail**: Read the full content of an email by its UID
+- **ReplyToEmail**: Send a reply to an email thread
+- **MarkEmailAsSeen**: Mark an email as read (prevents re-processing)
+- **Finish**: Complete the session
diff --git a/src/agents/shared/gadgets.ts b/src/agents/shared/gadgets.ts
index 581652b1..64a7e003 100644
--- a/src/agents/shared/gadgets.ts
+++ b/src/agents/shared/gadgets.ts
@@ -8,7 +8,13 @@ import { RipGrep } from '../../gadgets/RipGrep.js';
import { Sleep } from '../../gadgets/Sleep.js';
import { VerifyChanges } from '../../gadgets/VerifyChanges.js';
import { WriteFile } from '../../gadgets/WriteFile.js';
-import { ReadEmail, ReplyToEmail, SearchEmails, SendEmail } from '../../gadgets/email/index.js';
+import {
+ MarkEmailAsSeen,
+ ReadEmail,
+ ReplyToEmail,
+ SearchEmails,
+ SendEmail,
+} from '../../gadgets/email/index.js';
import {
CreatePR,
CreatePRReview,
@@ -78,7 +84,13 @@ export function buildWorkItemGadgets(caps: AgentCapabilities): CreateBuilderOpti
...(caps.canUpdateChecklists ? [new PMUpdateChecklistItem(), new PMDeleteChecklistItem()] : []),
// Email gadgets (gated by capability — disabled by default)
...(caps.canAccessEmail
- ? [new SendEmail(), new SearchEmails(), new ReadEmail(), new ReplyToEmail()]
+ ? [
+ new SendEmail(),
+ new SearchEmails(),
+ new ReadEmail(),
+ new ReplyToEmail(),
+ new MarkEmailAsSeen(),
+ ]
: []),
// Session control
new Finish(),
@@ -109,6 +121,22 @@ export function buildReviewGadgets(): CreateBuilderOptions['gadgets'] {
];
}
+/**
+ * Build gadgets for email-focused agents (email-joke).
+ *
+ * Minimal set: email operations + session control only.
+ * No file editing, no GitHub, no PM tools.
+ */
+export function buildEmailJokeGadgets(): CreateBuilderOptions['gadgets'] {
+ return [
+ new SearchEmails(),
+ new ReadEmail(),
+ new ReplyToEmail(),
+ new MarkEmailAsSeen(),
+ new Finish(),
+ ];
+}
+
/**
* Build gadgets for PR-modifying agents (respond-to-review, respond-to-ci,
* respond-to-pr-comment).
diff --git a/src/agents/shared/repository.ts b/src/agents/shared/repository.ts
index 71873e94..a3712f6a 100644
--- a/src/agents/shared/repository.ts
+++ b/src/agents/shared/repository.ts
@@ -17,8 +17,16 @@ export interface SetupRepositoryOptions {
export async function setupRepository(options: SetupRepositoryOptions): Promise {
const { project, log, agentType, prBranch, warmTsCache } = options;
- // Clone repo to temp directory
+ // Create temp directory for all agents
const repoDir = createTempDir(project.id);
+
+ // Skip cloning if no repo is configured (email-only agents)
+ if (!project.repo) {
+ log.info('No repo configured, skipping clone', { projectId: project.id, agentType });
+ return repoDir;
+ }
+
+ // Clone repo to temp directory
await cloneRepo(project, repoDir);
// Checkout PR branch if provided
diff --git a/src/api/routers/integrationsDiscovery.ts b/src/api/routers/integrationsDiscovery.ts
index 8249fdc2..24873435 100644
--- a/src/api/routers/integrationsDiscovery.ts
+++ b/src/api/routers/integrationsDiscovery.ts
@@ -1,13 +1,16 @@
import { TRPCError } from '@trpc/server';
-import { eq } from 'drizzle-orm';
+import { and, eq } from 'drizzle-orm';
+import { ImapFlow } from 'imapflow';
import { z } from 'zod';
import { getDb } from '../../db/client.js';
-import { decryptCredential } from '../../db/crypto.js';
-import { credentials } from '../../db/schema/index.js';
+import { decryptCredential, encryptCredential } from '../../db/crypto.js';
+import { credentials, integrationCredentials, projectIntegrations } from '../../db/schema/index.js';
+import { exchangeGmailCode, getGmailAuthUrl, getGmailUserInfo } from '../../email/gmail/oauth.js';
import { jiraClient, withJiraCredentials } from '../../jira/client.js';
import { trelloClient, withTrelloCredentials } from '../../trello/client.js';
import { logger } from '../../utils/logging.js';
import { protectedProcedure, router } from '../trpc.js';
+import { verifyProjectOrgAccess } from './_shared/projectAccess.js';
async function resolveCredentialValue(credentialId: number, orgId: string): Promise {
const db = getDb();
@@ -180,4 +183,344 @@ export const integrationsDiscoveryRouter = router({
});
}
}),
+
+ // ============================================================================
+ // Gmail OAuth endpoints
+ // ============================================================================
+
+ /**
+ * Generate a Gmail OAuth consent URL.
+ * The state parameter includes projectId for callback routing.
+ */
+ gmailOAuthUrl: protectedProcedure
+ .input(
+ z.object({
+ clientIdCredentialId: z.number(),
+ redirectUri: z.string().url(),
+ projectId: z.string(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ logger.debug('integrationsDiscovery.gmailOAuthUrl called', {
+ orgId: ctx.effectiveOrgId,
+ projectId: input.projectId,
+ });
+
+ // Verify project ownership
+ await verifyProjectOrgAccess(input.projectId, ctx.effectiveOrgId);
+
+ const clientId = await resolveCredentialValue(input.clientIdCredentialId, ctx.effectiveOrgId);
+
+ // Encode projectId, orgId, and timestamp in state for CSRF protection
+ const state = Buffer.from(
+ JSON.stringify({
+ projectId: input.projectId,
+ orgId: ctx.effectiveOrgId,
+ timestamp: Date.now(),
+ }),
+ ).toString('base64url');
+
+ const url = getGmailAuthUrl(clientId, input.redirectUri, state);
+ return { url, state };
+ }),
+
+ /**
+ * Exchange Gmail OAuth code for tokens and store credentials.
+ * Creates or updates gmail_email and gmail_refresh_token credentials.
+ */
+ gmailOAuthCallback: protectedProcedure
+ .input(
+ z.object({
+ clientIdCredentialId: z.number(),
+ clientSecretCredentialId: z.number(),
+ code: z.string(),
+ redirectUri: z.string().url(),
+ state: z.string(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ // Decode and validate state parameter for CSRF protection
+ let stateData: { projectId: string; orgId: string; timestamp: number };
+ try {
+ stateData = JSON.parse(
+ Buffer.from(input.state.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString(),
+ );
+ } catch {
+ throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid state parameter' });
+ }
+
+ // Validate timestamp (within 10 minutes)
+ const STATE_EXPIRY_MS = 10 * 60 * 1000;
+ if (Date.now() - stateData.timestamp > STATE_EXPIRY_MS) {
+ throw new TRPCError({ code: 'BAD_REQUEST', message: 'OAuth state expired' });
+ }
+
+ // Validate orgId matches the current user's org
+ if (stateData.orgId !== ctx.effectiveOrgId) {
+ throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid state parameter' });
+ }
+
+ const projectId = stateData.projectId;
+
+ // Verify project ownership
+ await verifyProjectOrgAccess(projectId, ctx.effectiveOrgId);
+
+ logger.debug('integrationsDiscovery.gmailOAuthCallback called', {
+ orgId: ctx.effectiveOrgId,
+ projectId,
+ });
+
+ const [clientId, clientSecret] = await Promise.all([
+ resolveCredentialValue(input.clientIdCredentialId, ctx.effectiveOrgId),
+ resolveCredentialValue(input.clientSecretCredentialId, ctx.effectiveOrgId),
+ ]);
+
+ // Exchange code for tokens
+ const tokens = await exchangeGmailCode(clientId, clientSecret, input.code, input.redirectUri);
+
+ if (!tokens.refresh_token) {
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message: 'No refresh token received. User may need to revoke access and re-authorize.',
+ });
+ }
+
+ // Get user email
+ const userInfo = await getGmailUserInfo(tokens.access_token);
+
+ const db = getDb();
+
+ // Ensure Gmail integration exists for the project
+ const [existingIntegration] = await db
+ .select({ id: projectIntegrations.id })
+ .from(projectIntegrations)
+ .where(
+ and(
+ eq(projectIntegrations.projectId, projectId),
+ eq(projectIntegrations.category, 'email'),
+ ),
+ );
+
+ let integrationId: number;
+ if (existingIntegration) {
+ // Update to gmail provider
+ await db
+ .update(projectIntegrations)
+ .set({ provider: 'gmail', config: {}, updatedAt: new Date() })
+ .where(eq(projectIntegrations.id, existingIntegration.id));
+ integrationId = existingIntegration.id;
+ } else {
+ // Create new gmail integration
+ const [newIntegration] = await db
+ .insert(projectIntegrations)
+ .values({
+ projectId,
+ category: 'email',
+ provider: 'gmail',
+ config: {},
+ })
+ .returning({ id: projectIntegrations.id });
+ integrationId = newIntegration.id;
+ }
+
+ // Create or update gmail_email credential
+ const emailCredName = `Gmail: ${userInfo.email}`;
+ const [existingEmailCred] = await db
+ .select({ id: credentials.id })
+ .from(credentials)
+ .where(
+ and(
+ eq(credentials.orgId, ctx.effectiveOrgId),
+ eq(credentials.envVarKey, 'EMAIL_GMAIL_ADDRESS'),
+ eq(credentials.name, emailCredName),
+ ),
+ );
+
+ let emailCredId: number;
+ if (existingEmailCred) {
+ await db
+ .update(credentials)
+ .set({
+ value: encryptCredential(userInfo.email, ctx.effectiveOrgId),
+ updatedAt: new Date(),
+ })
+ .where(eq(credentials.id, existingEmailCred.id));
+ emailCredId = existingEmailCred.id;
+ } else {
+ const [newCred] = await db
+ .insert(credentials)
+ .values({
+ orgId: ctx.effectiveOrgId,
+ name: emailCredName,
+ envVarKey: 'EMAIL_GMAIL_ADDRESS',
+ value: encryptCredential(userInfo.email, ctx.effectiveOrgId),
+ isDefault: false,
+ })
+ .returning({ id: credentials.id });
+ emailCredId = newCred.id;
+ }
+
+ // Create or update gmail_refresh_token credential
+ const refreshCredName = `Gmail Refresh Token: ${userInfo.email}`;
+ const [existingRefreshCred] = await db
+ .select({ id: credentials.id })
+ .from(credentials)
+ .where(
+ and(
+ eq(credentials.orgId, ctx.effectiveOrgId),
+ eq(credentials.envVarKey, 'EMAIL_GMAIL_REFRESH_TOKEN'),
+ eq(credentials.name, refreshCredName),
+ ),
+ );
+
+ let refreshCredId: number;
+ if (existingRefreshCred) {
+ await db
+ .update(credentials)
+ .set({
+ value: encryptCredential(tokens.refresh_token, ctx.effectiveOrgId),
+ updatedAt: new Date(),
+ })
+ .where(eq(credentials.id, existingRefreshCred.id));
+ refreshCredId = existingRefreshCred.id;
+ } else {
+ const [newCred] = await db
+ .insert(credentials)
+ .values({
+ orgId: ctx.effectiveOrgId,
+ name: refreshCredName,
+ envVarKey: 'EMAIL_GMAIL_REFRESH_TOKEN',
+ value: encryptCredential(tokens.refresh_token, ctx.effectiveOrgId),
+ isDefault: false,
+ })
+ .returning({ id: credentials.id });
+ refreshCredId = newCred.id;
+ }
+
+ // Link credentials to integration
+ // Delete any existing credential links for this integration
+ await db
+ .delete(integrationCredentials)
+ .where(eq(integrationCredentials.integrationId, integrationId));
+
+ // Insert new credential links
+ await db.insert(integrationCredentials).values([
+ { integrationId, role: 'gmail_email', credentialId: emailCredId },
+ { integrationId, role: 'gmail_refresh_token', credentialId: refreshCredId },
+ ]);
+
+ logger.info('Gmail OAuth credentials stored successfully', {
+ projectId,
+ email: userInfo.email,
+ });
+
+ return { email: userInfo.email };
+ }),
+
+ /**
+ * Verify Gmail OAuth connection by testing IMAP login.
+ */
+ verifyGmail: protectedProcedure
+ .input(
+ z.object({
+ clientIdCredentialId: z.number(),
+ clientSecretCredentialId: z.number(),
+ refreshTokenCredentialId: z.number(),
+ email: z.string().email(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ logger.debug('integrationsDiscovery.verifyGmail called', { orgId: ctx.effectiveOrgId });
+
+ const [clientId, clientSecret, refreshToken] = await Promise.all([
+ resolveCredentialValue(input.clientIdCredentialId, ctx.effectiveOrgId),
+ resolveCredentialValue(input.clientSecretCredentialId, ctx.effectiveOrgId),
+ resolveCredentialValue(input.refreshTokenCredentialId, ctx.effectiveOrgId),
+ ]);
+
+ try {
+ // Exchange refresh token for access token
+ const { exchangeGmailCode: _, refreshGmailAccessToken } = await import(
+ '../../email/gmail/oauth.js'
+ );
+ const { accessToken } = await refreshGmailAccessToken(clientId, clientSecret, refreshToken);
+
+ // Test IMAP connection
+ const client = new ImapFlow({
+ host: 'imap.gmail.com',
+ port: 993,
+ secure: true,
+ auth: {
+ user: input.email,
+ accessToken,
+ },
+ logger: false,
+ connectionTimeout: 15000,
+ greetingTimeout: 10000,
+ });
+
+ await client.connect();
+ await client.logout();
+
+ return { success: true, email: input.email };
+ } catch (err) {
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message: `Gmail verification failed: ${err instanceof Error ? err.message : String(err)}`,
+ });
+ }
+ }),
+
+ /**
+ * Verify IMAP connection with password auth.
+ */
+ verifyImap: protectedProcedure
+ .input(
+ z.object({
+ hostCredentialId: z.number(),
+ portCredentialId: z.number(),
+ usernameCredentialId: z.number(),
+ passwordCredentialId: z.number(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ logger.debug('integrationsDiscovery.verifyImap called', { orgId: ctx.effectiveOrgId });
+
+ const [host, portStr, username, password] = await Promise.all([
+ resolveCredentialValue(input.hostCredentialId, ctx.effectiveOrgId),
+ resolveCredentialValue(input.portCredentialId, ctx.effectiveOrgId),
+ resolveCredentialValue(input.usernameCredentialId, ctx.effectiveOrgId),
+ resolveCredentialValue(input.passwordCredentialId, ctx.effectiveOrgId),
+ ]);
+
+ const port = Number.parseInt(portStr, 10);
+ if (Number.isNaN(port)) {
+ throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid port number' });
+ }
+
+ try {
+ const client = new ImapFlow({
+ host,
+ port,
+ secure: true,
+ auth: {
+ user: username,
+ pass: password,
+ },
+ logger: false,
+ connectionTimeout: 15000,
+ greetingTimeout: 10000,
+ });
+
+ await client.connect();
+ await client.logout();
+
+ return { success: true, email: username };
+ } catch (err) {
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message: `IMAP verification failed: ${err instanceof Error ? err.message : String(err)}`,
+ });
+ }
+ }),
});
diff --git a/src/api/routers/projects.ts b/src/api/routers/projects.ts
index 5cc571ee..e772637a 100644
--- a/src/api/routers/projects.ts
+++ b/src/api/routers/projects.ts
@@ -68,7 +68,7 @@ export const projectsRouter = router({
.min(1)
.regex(/^[a-z0-9-]+$/),
name: z.string().min(1),
- repo: z.string().min(1),
+ repo: z.string().min(1).optional(),
baseBranch: z.string().optional(),
branchPrefix: z.string().optional(),
model: z.string().nullish(),
@@ -121,7 +121,7 @@ export const projectsRouter = router({
.input(
z.object({
projectId: z.string(),
- category: z.enum(['pm', 'scm']),
+ category: z.enum(['pm', 'scm', 'email']),
provider: z.string().min(1),
config: z.record(z.unknown()),
triggers: z.record(z.boolean()).optional(),
@@ -142,8 +142,8 @@ export const projectsRouter = router({
.input(
z.object({
projectId: z.string(),
- category: z.enum(['pm', 'scm']),
- triggers: z.record(z.union([z.boolean(), z.record(z.boolean())])),
+ category: z.enum(['pm', 'scm', 'email']),
+ triggers: z.record(z.union([z.boolean(), z.string().nullable(), z.record(z.boolean())])),
}),
)
.mutation(async ({ ctx, input }) => {
@@ -162,7 +162,7 @@ export const projectsRouter = router({
// Integration Credentials
integrationCredentials: router({
list: protectedProcedure
- .input(z.object({ projectId: z.string(), category: z.enum(['pm', 'scm']) }))
+ .input(z.object({ projectId: z.string(), category: z.enum(['pm', 'scm', 'email']) }))
.query(async ({ ctx, input }) => {
await verifyProjectOwnership(input.projectId, ctx.effectiveOrgId);
const integration = await getIntegrationByProjectAndCategory(
@@ -177,7 +177,7 @@ export const projectsRouter = router({
.input(
z.object({
projectId: z.string(),
- category: z.enum(['pm', 'scm']),
+ category: z.enum(['pm', 'scm', 'email']),
role: z.string().min(1),
credentialId: z.number(),
}),
@@ -202,7 +202,7 @@ export const projectsRouter = router({
.input(
z.object({
projectId: z.string(),
- category: z.enum(['pm', 'scm']),
+ category: z.enum(['pm', 'scm', 'email']),
role: z.string().min(1),
}),
)
diff --git a/src/api/routers/webhooks/github.ts b/src/api/routers/webhooks/github.ts
index 4f263675..b9e71031 100644
--- a/src/api/routers/webhooks/github.ts
+++ b/src/api/routers/webhooks/github.ts
@@ -11,6 +11,7 @@ const GITHUB_WEBHOOK_EVENTS = [
export async function githubListWebhooks(ctx: ProjectContext): Promise {
if (!ctx.githubToken) return [];
+ if (!ctx.repo) return []; // No repo configured
const octokit = new Octokit({ auth: ctx.githubToken });
const { owner, repo } = parseRepoFullName(ctx.repo);
const { data } = await octokit.repos.listWebhooks({ owner, repo });
@@ -21,6 +22,9 @@ export async function githubCreateWebhook(
ctx: ProjectContext,
callbackURL: string,
): Promise {
+ if (!ctx.repo) {
+ throw new Error('Cannot create GitHub webhook: no repo configured for this project');
+ }
const octokit = new Octokit({ auth: ctx.githubToken });
const { owner, repo } = parseRepoFullName(ctx.repo);
const { data } = await octokit.repos.createWebhook({
@@ -34,6 +38,9 @@ export async function githubCreateWebhook(
}
export async function githubDeleteWebhook(ctx: ProjectContext, hookId: number): Promise {
+ if (!ctx.repo) {
+ throw new Error('Cannot delete GitHub webhook: no repo configured for this project');
+ }
const octokit = new Octokit({ auth: ctx.githubToken });
const { owner, repo } = parseRepoFullName(ctx.repo);
await octokit.repos.deleteWebhook({ owner, repo, hook_id: hookId });
diff --git a/src/api/routers/webhooks/types.ts b/src/api/routers/webhooks/types.ts
index bf50edca..45d6d50b 100644
--- a/src/api/routers/webhooks/types.ts
+++ b/src/api/routers/webhooks/types.ts
@@ -29,7 +29,7 @@ export interface JiraWebhookInfo {
export interface ProjectContext {
projectId: string;
orgId: string;
- repo: string;
+ repo?: string; // optional for email-only projects
pmType: 'trello' | 'jira';
boardId?: string;
jiraBaseUrl?: string;
diff --git a/src/backends/agent-profiles.ts b/src/backends/agent-profiles.ts
index 765e61b1..1db2ab3d 100644
--- a/src/backends/agent-profiles.ts
+++ b/src/backends/agent-profiles.ts
@@ -74,6 +74,8 @@ function buildTaskPromptContext(input: AgentInput): TaskPromptContext {
prBranch: input.prBranch,
commentBody: input.triggerCommentBody as string | undefined,
commentPath: (input.triggerCommentPath as string) || undefined,
+ // Email-joke agent fields
+ senderEmail: input.senderEmail as string | undefined,
};
}
diff --git a/src/cli/dashboard/email/integration-set.ts b/src/cli/dashboard/email/integration-set.ts
new file mode 100644
index 00000000..c0155009
--- /dev/null
+++ b/src/cli/dashboard/email/integration-set.ts
@@ -0,0 +1,60 @@
+import { Args, Flags } from '@oclif/core';
+import { DashboardCommand } from '../_shared/base.js';
+
+export default class EmailIntegrationSet extends DashboardCommand {
+ static override description = 'Set email integration for a project.';
+
+ static override args = {
+ projectId: Args.string({ description: 'Project ID', required: true }),
+ };
+
+ static override flags = {
+ ...DashboardCommand.baseFlags,
+ provider: Flags.string({
+ description: 'Email provider (gmail or imap)',
+ required: true,
+ options: ['gmail', 'imap'],
+ }),
+ config: Flags.string({
+ description: 'Config as JSON string (optional)',
+ default: '{}',
+ }),
+ };
+
+ async run(): Promise {
+ const { args, flags } = await this.parse(EmailIntegrationSet);
+
+ let config: Record;
+ try {
+ config = JSON.parse(flags.config) as Record;
+ } catch {
+ this.error('Invalid JSON in --config flag.');
+ }
+
+ try {
+ await this.client.projects.integrations.upsert.mutate({
+ projectId: args.projectId,
+ category: 'email',
+ provider: flags.provider,
+ config,
+ });
+
+ if (flags.json) {
+ this.outputJson({ ok: true });
+ return;
+ }
+
+ this.log(`Set email/${flags.provider} integration for project: ${args.projectId}`);
+
+ if (flags.provider === 'gmail') {
+ this.log('Note: Run "cascade email oauth" to authenticate Gmail.');
+ } else {
+ this.log(
+ 'Note: Link IMAP credentials using "cascade projects integration-credential-set".',
+ );
+ }
+ } catch (err) {
+ this.handleError(err);
+ }
+ }
+}
diff --git a/src/cli/dashboard/email/joke-config.ts b/src/cli/dashboard/email/joke-config.ts
new file mode 100644
index 00000000..230ec42e
--- /dev/null
+++ b/src/cli/dashboard/email/joke-config.ts
@@ -0,0 +1,116 @@
+import { Args, Flags } from '@oclif/core';
+import { z } from 'zod';
+import { DashboardCommand } from '../_shared/base.js';
+
+type EmailIntegration = { category: string; triggers: unknown };
+type EmailTriggers = { senderEmail?: string | null };
+
+function extractSenderEmail(integration: EmailIntegration | undefined): string | null {
+ if (!integration?.triggers) return null;
+ return (integration.triggers as EmailTriggers).senderEmail ?? null;
+}
+
+export default class EmailJokeConfig extends DashboardCommand {
+ static override description = 'Configure email-joke agent sender filter.';
+
+ static override args = {
+ projectId: Args.string({ description: 'Project ID', required: true }),
+ };
+
+ static override flags = {
+ ...DashboardCommand.baseFlags,
+ 'sender-email': Flags.string({
+ description: 'Email address to filter (only respond to emails from this sender)',
+ required: false,
+ }),
+ clear: Flags.boolean({
+ description: 'Clear the sender email filter',
+ default: false,
+ }),
+ };
+
+ static override examples = [
+ '<%= config.bin %> <%= command.id %> my-project --sender-email friend@example.com',
+ '<%= config.bin %> <%= command.id %> my-project --clear',
+ ];
+
+ private displayCurrentConfig(
+ emailIntegration: EmailIntegration | undefined,
+ useJson: boolean,
+ ): void {
+ const senderEmail = extractSenderEmail(emailIntegration);
+
+ if (useJson) {
+ this.outputJson({ senderEmail });
+ return;
+ }
+
+ if (senderEmail) {
+ this.log(`Current sender filter: ${senderEmail}`);
+ } else if (emailIntegration) {
+ this.log('Current sender filter: (none)');
+ } else {
+ this.log('No email integration configured for this project.');
+ }
+ }
+
+ async run(): Promise {
+ const { args, flags } = await this.parse(EmailJokeConfig);
+
+ try {
+ // Validate email address format if provided
+ if (flags['sender-email']) {
+ const result = z.string().email().safeParse(flags['sender-email']);
+ if (!result.success) {
+ this.error('Invalid email address format');
+ }
+ }
+
+ // Build triggers update
+ const triggers: Record = {};
+ if (flags['sender-email']) {
+ triggers.senderEmail = flags['sender-email'];
+ } else if (flags.clear) {
+ triggers.senderEmail = null;
+ }
+
+ // Fetch integrations to check if email integration exists
+ const integrations = await this.client.projects.integrations.list.query({
+ projectId: args.projectId,
+ });
+
+ const emailIntegration = (integrations as unknown as EmailIntegration[]).find(
+ (i) => i.category === 'email',
+ );
+
+ // No update requested, just show current config
+ if (Object.keys(triggers).length === 0) {
+ this.displayCurrentConfig(emailIntegration, flags.json);
+ return;
+ }
+
+ // Check if email integration exists before attempting update
+ if (!emailIntegration) {
+ this.error(
+ 'No email integration configured for this project. Configure email integration first.',
+ );
+ }
+
+ await this.client.projects.integrations.updateTriggers.mutate({
+ projectId: args.projectId,
+ category: 'email',
+ triggers,
+ });
+
+ if (flags.json) {
+ this.outputJson({ success: true, triggers });
+ } else if (triggers.senderEmail) {
+ this.log(`Sender filter set to: ${triggers.senderEmail}`);
+ } else {
+ this.log('Sender filter cleared.');
+ }
+ } catch (err) {
+ this.handleError(err);
+ }
+ }
+}
diff --git a/src/cli/dashboard/email/oauth.ts b/src/cli/dashboard/email/oauth.ts
new file mode 100644
index 00000000..d12e6287
--- /dev/null
+++ b/src/cli/dashboard/email/oauth.ts
@@ -0,0 +1,176 @@
+import * as http from 'node:http';
+import * as url from 'node:url';
+import { Args, Flags } from '@oclif/core';
+import { DashboardCommand } from '../_shared/base.js';
+
+export default class EmailOAuth extends DashboardCommand {
+ static override description =
+ 'Authenticate Gmail via OAuth. Opens browser and runs local callback server.';
+
+ static override args = {
+ projectId: Args.string({ description: 'Project ID', required: true }),
+ };
+
+ static override flags = {
+ ...DashboardCommand.baseFlags,
+ port: Flags.integer({
+ description: 'Local callback server port',
+ default: 8085,
+ }),
+ };
+
+ async run(): Promise {
+ const { args, flags } = await this.parse(EmailOAuth);
+
+ try {
+ // Find Google OAuth credentials
+ const credentials = await this.client.credentials.list.query();
+ const clientIdCred = credentials.find(
+ (c: { envVarKey: string }) => c.envVarKey === 'GOOGLE_OAUTH_CLIENT_ID',
+ ) as { id: number } | undefined;
+ const clientSecretCred = credentials.find(
+ (c: { envVarKey: string }) => c.envVarKey === 'GOOGLE_OAUTH_CLIENT_SECRET',
+ ) as { id: number } | undefined;
+
+ if (!clientIdCred || !clientSecretCred) {
+ this.error(
+ 'Google OAuth credentials not configured. Add GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET credentials first.',
+ );
+ }
+
+ const redirectUri = `http://127.0.0.1:${flags.port}/callback`;
+
+ // Get OAuth URL
+ const { url: authUrl } = await this.client.integrationsDiscovery.gmailOAuthUrl.mutate({
+ clientIdCredentialId: clientIdCred.id,
+ redirectUri,
+ projectId: args.projectId,
+ });
+
+ this.log('Opening browser for Google authorization...');
+ this.log(`If browser doesn't open, visit: ${authUrl}`);
+
+ // Open browser
+ const open = await import('open');
+ await open.default(authUrl);
+
+ // Start callback server and wait for code and state
+ const { code, state } = await this.waitForCallback(flags.port);
+
+ this.log('Received authorization code. Exchanging for tokens...');
+
+ // Exchange code - pass the state for server-side CSRF validation
+ const result = await this.client.integrationsDiscovery.gmailOAuthCallback.mutate({
+ clientIdCredentialId: clientIdCred.id,
+ clientSecretCredentialId: clientSecretCred.id,
+ code,
+ redirectUri,
+ state,
+ });
+
+ if (flags.json) {
+ this.outputJson({ email: result.email, success: true });
+ return;
+ }
+
+ this.log(`Gmail connected successfully for: ${result.email}`);
+ this.log('Email integration has been created and credentials linked.');
+ } catch (err) {
+ this.handleError(err);
+ }
+ }
+
+ private sendHtmlResponse(
+ res: http.ServerResponse,
+ title: string,
+ message: string,
+ isError: boolean,
+ ): void {
+ const color = isError ? '#dc2626' : '#16a34a';
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ res.end(`
+
+
+ ${title}
+ ${message}
+ You can close this window.
+
+
+ `);
+ }
+
+ private handleOAuthCallback(
+ parsedUrl: url.UrlWithParsedQuery,
+ res: http.ServerResponse,
+ server: http.Server,
+ resolve: (value: { code: string; state: string }) => void,
+ reject: (reason: Error) => void,
+ ): boolean {
+ if (parsedUrl.pathname !== '/callback') {
+ return false;
+ }
+
+ const code = parsedUrl.query.code as string | undefined;
+ const state = parsedUrl.query.state as string | undefined;
+ const error = parsedUrl.query.error as string | undefined;
+
+ if (error) {
+ this.sendHtmlResponse(res, 'Authorization Failed', error, true);
+ server.close();
+ reject(new Error(`OAuth error: ${error}`));
+ return true;
+ }
+
+ if (code && state) {
+ this.sendHtmlResponse(
+ res,
+ 'Authorization Successful!',
+ 'You can close this window and return to the terminal.',
+ false,
+ );
+ server.close();
+ resolve({ code, state });
+ return true;
+ }
+
+ if (code && !state) {
+ this.sendHtmlResponse(res, 'Authorization Failed', 'Missing state parameter', true);
+ server.close();
+ reject(new Error('OAuth callback missing state parameter'));
+ return true;
+ }
+
+ return false;
+ }
+
+ private waitForCallback(port: number): Promise<{ code: string; state: string }> {
+ return new Promise((resolve, reject) => {
+ const server = http.createServer((req, res) => {
+ const parsedUrl = url.parse(req.url ?? '', true);
+
+ const handled = this.handleOAuthCallback(parsedUrl, res, server, resolve, reject);
+ if (!handled) {
+ res.writeHead(404);
+ res.end('Not found');
+ }
+ });
+
+ server.listen(port, '127.0.0.1', () => {
+ this.log(`Waiting for OAuth callback on http://127.0.0.1:${port}/callback`);
+ });
+
+ server.on('error', (err) => {
+ reject(new Error(`Failed to start callback server: ${err.message}`));
+ });
+
+ // Timeout after 5 minutes
+ setTimeout(
+ () => {
+ server.close();
+ reject(new Error('OAuth callback timeout (5 minutes)'));
+ },
+ 5 * 60 * 1000,
+ );
+ });
+ }
+}
diff --git a/src/cli/dashboard/email/verify.ts b/src/cli/dashboard/email/verify.ts
new file mode 100644
index 00000000..f81b41e3
--- /dev/null
+++ b/src/cli/dashboard/email/verify.ts
@@ -0,0 +1,116 @@
+import { Args } from '@oclif/core';
+import { DashboardCommand } from '../_shared/base.js';
+
+export default class EmailVerify extends DashboardCommand {
+ static override description = 'Verify email integration connection for a project.';
+
+ static override args = {
+ projectId: Args.string({ description: 'Project ID', required: true }),
+ };
+
+ static override flags = {
+ ...DashboardCommand.baseFlags,
+ };
+
+ private async verifyGmail(credMap: Map, jsonOutput: boolean): Promise {
+ const orgCredentials = await this.client.credentials.list.query();
+ const clientIdCred = orgCredentials.find(
+ (c: { envVarKey: string }) => c.envVarKey === 'GOOGLE_OAUTH_CLIENT_ID',
+ ) as { id: number } | undefined;
+ const clientSecretCred = orgCredentials.find(
+ (c: { envVarKey: string }) => c.envVarKey === 'GOOGLE_OAUTH_CLIENT_SECRET',
+ ) as { id: number } | undefined;
+
+ const refreshTokenCredId = credMap.get('gmail_refresh_token');
+ const gmailEmailCredId = credMap.get('gmail_email');
+
+ if (!clientIdCred || !clientSecretCred) {
+ this.error('Google OAuth credentials not configured at org level.');
+ }
+
+ if (!refreshTokenCredId || !gmailEmailCredId) {
+ this.error('Gmail credentials not linked to project. Run "cascade email oauth" first.');
+ }
+
+ const gmailCreds = orgCredentials.find((c: { id: number }) => c.id === gmailEmailCredId) as
+ | { value: string }
+ | undefined;
+
+ const result = await this.client.integrationsDiscovery.verifyGmail.mutate({
+ clientIdCredentialId: clientIdCred.id,
+ clientSecretCredentialId: clientSecretCred.id,
+ refreshTokenCredentialId: refreshTokenCredId,
+ email: gmailCreds?.value ?? '',
+ });
+
+ if (jsonOutput) {
+ this.outputJson(result);
+ return;
+ }
+
+ this.log(`Gmail connection verified for: ${result.email}`);
+ }
+
+ private async verifyImap(credMap: Map, jsonOutput: boolean): Promise {
+ const hostCredId = credMap.get('imap_host');
+ const portCredId = credMap.get('imap_port');
+ const usernameCredId = credMap.get('username');
+ const passwordCredId = credMap.get('password');
+
+ if (!hostCredId || !portCredId || !usernameCredId || !passwordCredId) {
+ this.error('IMAP credentials not fully configured for project.');
+ }
+
+ const result = await this.client.integrationsDiscovery.verifyImap.mutate({
+ hostCredentialId: hostCredId,
+ portCredentialId: portCredId,
+ usernameCredentialId: usernameCredId,
+ passwordCredentialId: passwordCredId,
+ });
+
+ if (jsonOutput) {
+ this.outputJson(result);
+ return;
+ }
+
+ this.log(`IMAP connection verified for: ${result.email}`);
+ }
+
+ async run(): Promise {
+ const { args, flags } = await this.parse(EmailVerify);
+
+ try {
+ const integrations = await this.client.projects.integrations.list.query({
+ projectId: args.projectId,
+ });
+
+ const emailIntegration = integrations.find(
+ (i: { category: string }) => i.category === 'email',
+ ) as { provider: string } | undefined;
+
+ if (!emailIntegration) {
+ this.error('No email integration configured for this project.');
+ }
+
+ const credentials = await this.client.projects.integrationCredentials.list.query({
+ projectId: args.projectId,
+ category: 'email',
+ });
+
+ const credMap = new Map();
+ for (const c of credentials as Array<{ role: string; credentialId: number }>) {
+ credMap.set(c.role, c.credentialId);
+ }
+
+ if (emailIntegration.provider === 'gmail') {
+ await this.verifyGmail(credMap, flags.json);
+ } else if (emailIntegration.provider === 'imap') {
+ await this.verifyImap(credMap, flags.json);
+ } else {
+ this.error(`Unknown email provider: ${emailIntegration.provider}`);
+ }
+ } catch (err) {
+ this.handleError(err);
+ }
+ }
+}
diff --git a/src/config/integrationRoles.ts b/src/config/integrationRoles.ts
index 11157597..df865755 100644
--- a/src/config/integrationRoles.ts
+++ b/src/config/integrationRoles.ts
@@ -1,11 +1,12 @@
export type IntegrationCategory = 'pm' | 'scm' | 'email';
-export type IntegrationProvider = 'trello' | 'jira' | 'github' | 'imap';
+export type IntegrationProvider = 'trello' | 'jira' | 'github' | 'imap' | 'gmail';
export const PROVIDER_CATEGORY: Record = {
trello: 'pm',
jira: 'pm',
github: 'scm',
imap: 'email',
+ gmail: 'email',
};
export interface CredentialRoleDef {
@@ -39,4 +40,8 @@ export const PROVIDER_CREDENTIAL_ROLES: Record r.role === role);
if (roleDef) return roleDef.envVarKey;
diff --git a/src/config/schema.ts b/src/config/schema.ts
index 2836a4ab..245b7d7c 100644
--- a/src/config/schema.ts
+++ b/src/config/schema.ts
@@ -36,7 +36,10 @@ export const ProjectConfigSchema = z.object({
id: z.string().min(1),
orgId: z.string().min(1),
name: z.string().min(1),
- repo: z.string().regex(/^[^/]+\/[^/]+$/, 'Must be in format "owner/repo"'),
+ repo: z
+ .string()
+ .regex(/^[^/]+\/[^/]+$/, 'Must be in format "owner/repo"')
+ .optional(),
baseBranch: z.string().default('main'),
branchPrefix: z.string().default('feature/'),
diff --git a/src/config/triggerConfig.ts b/src/config/triggerConfig.ts
index 32eb3690..201a88a9 100644
--- a/src/config/triggerConfig.ts
+++ b/src/config/triggerConfig.ts
@@ -101,6 +101,45 @@ export type TrelloTriggerConfig = z.infer;
export type JiraTriggerConfig = z.infer;
export type GitHubTriggerConfig = z.infer;
+// ============================================================================
+// Email Trigger Config
+// ============================================================================
+
+/**
+ * Trigger configuration for email-joke agent.
+ * Stored in project_integrations.triggers for email category.
+ */
+export const EmailJokeTriggerConfigSchema = z.object({
+ /** Email address filter — only respond to emails from this sender */
+ senderEmail: z.string().email().nullable().optional(),
+});
+
+export type EmailJokeTriggerConfig = z.infer;
+
+/**
+ * Resolve email-joke trigger config with defaults.
+ * Also used for type-safe parsing of raw trigger objects.
+ */
+export function resolveEmailJokeTriggerConfig(
+ config: Partial | undefined,
+): EmailJokeTriggerConfig {
+ return {
+ senderEmail: config?.senderEmail ?? undefined,
+ };
+}
+
+/**
+ * Parse and validate email-joke trigger config from unknown input.
+ * Returns a properly typed EmailJokeTriggerConfig.
+ */
+export function parseEmailJokeTriggers(triggers: unknown): EmailJokeTriggerConfig {
+ if (!triggers || typeof triggers !== 'object') {
+ return { senderEmail: undefined };
+ }
+ const result = EmailJokeTriggerConfigSchema.safeParse(triggers);
+ return result.success ? result.data : { senderEmail: undefined };
+}
+
// ============================================================================
// Review Trigger Resolution
// ============================================================================
diff --git a/src/db/migrations/0018_gmail_oauth.sql b/src/db/migrations/0018_gmail_oauth.sql
new file mode 100644
index 00000000..378fd8c9
--- /dev/null
+++ b/src/db/migrations/0018_gmail_oauth.sql
@@ -0,0 +1,43 @@
+-- 0018_gmail_oauth.sql
+-- Add Gmail OAuth provider support for email integration.
+
+BEGIN;
+
+-- ============================================================================
+-- 1. Update category/provider CHECK constraint to include gmail provider
+-- ============================================================================
+
+ALTER TABLE project_integrations
+ DROP CONSTRAINT IF EXISTS chk_integration_category_provider;
+
+ALTER TABLE project_integrations
+ ADD CONSTRAINT chk_integration_category_provider
+ CHECK (
+ (category = 'pm' AND provider IN ('trello', 'jira'))
+ OR (category = 'scm' AND provider IN ('github'))
+ OR (category = 'email' AND provider IN ('imap', 'gmail'))
+ );
+
+-- ============================================================================
+-- 2. Update credential role CHECK constraint to include Gmail OAuth roles
+-- ============================================================================
+
+ALTER TABLE integration_credentials
+ DROP CONSTRAINT IF EXISTS chk_integration_credential_role;
+
+ALTER TABLE integration_credentials
+ ADD CONSTRAINT chk_integration_credential_role
+ CHECK (
+ role IN (
+ -- PM roles
+ 'api_key', 'token', 'email', 'api_token',
+ -- SCM roles
+ 'implementer_token', 'reviewer_token',
+ -- Email/IMAP roles
+ 'imap_host', 'imap_port', 'smtp_host', 'smtp_port', 'username', 'password',
+ -- Email/Gmail OAuth roles
+ 'gmail_email', 'gmail_refresh_token'
+ )
+ );
+
+COMMIT;
diff --git a/src/db/migrations/0019_make_repo_optional.sql b/src/db/migrations/0019_make_repo_optional.sql
new file mode 100644
index 00000000..5db825e5
--- /dev/null
+++ b/src/db/migrations/0019_make_repo_optional.sql
@@ -0,0 +1,7 @@
+-- Make repo column optional to support email-only projects
+ALTER TABLE projects ALTER COLUMN repo DROP NOT NULL;
+
+-- Drop the existing unique index and recreate it as a partial index
+-- (only enforces uniqueness for non-null values)
+DROP INDEX IF EXISTS uq_projects_repo;
+CREATE UNIQUE INDEX uq_projects_repo ON projects (repo) WHERE repo IS NOT NULL;
diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json
index 246441ef..58df4aaa 100644
--- a/src/db/migrations/meta/_journal.json
+++ b/src/db/migrations/meta/_journal.json
@@ -127,6 +127,20 @@
"when": 1752000000000,
"tag": "0017_email_integration",
"breakpoints": false
+ },
+ {
+ "idx": 18,
+ "version": "7",
+ "when": 1753000000000,
+ "tag": "0018_gmail_oauth",
+ "breakpoints": false
+ },
+ {
+ "idx": 19,
+ "version": "7",
+ "when": 1754000000000,
+ "tag": "0019_make_repo_optional",
+ "breakpoints": false
}
]
}
diff --git a/src/db/repositories/configMapper.ts b/src/db/repositories/configMapper.ts
index 398f11fa..5ccc239c 100644
--- a/src/db/repositories/configMapper.ts
+++ b/src/db/repositories/configMapper.ts
@@ -85,7 +85,7 @@ export interface ProjectConfigRaw {
id: string;
orgId: string;
name: string;
- repo: string;
+ repo?: string;
baseBranch: string;
branchPrefix: string;
pm: { type: string };
@@ -127,7 +127,7 @@ type ProjectRow = {
id: string;
orgId: string;
name: string;
- repo: string;
+ repo: string | null;
baseBranch: string | null;
branchPrefix: string | null;
model: string | null;
@@ -270,7 +270,7 @@ export function mapProjectRow({
id: row.id,
orgId: row.orgId,
name: row.name,
- repo: row.repo,
+ repo: row.repo ?? undefined,
baseBranch: row.baseBranch ?? 'main',
branchPrefix: row.branchPrefix ?? 'feature/',
pm: { type: pmType },
diff --git a/src/db/repositories/credentialsRepository.ts b/src/db/repositories/credentialsRepository.ts
index 4a1e7ab6..56e1c5ef 100644
--- a/src/db/repositories/credentialsRepository.ts
+++ b/src/db/repositories/credentialsRepository.ts
@@ -118,6 +118,28 @@ export async function resolveAllOrgCredentials(orgId: string): Promise {
+ const db = getDb();
+ const [row] = await db
+ .select({ provider: projectIntegrations.provider })
+ .from(projectIntegrations)
+ .where(
+ and(eq(projectIntegrations.projectId, projectId), eq(projectIntegrations.category, category)),
+ );
+
+ return row?.provider ?? null;
+}
+
// ============================================================================
// CRUD for credentials (org-scoped pool)
// ============================================================================
diff --git a/src/db/repositories/settingsRepository.ts b/src/db/repositories/settingsRepository.ts
index 5dd49306..fd87bfb7 100644
--- a/src/db/repositories/settingsRepository.ts
+++ b/src/db/repositories/settingsRepository.ts
@@ -87,7 +87,7 @@ export async function createProject(
data: {
id: string;
name: string;
- repo: string;
+ repo?: string;
baseBranch?: string;
branchPrefix?: string;
model?: string | null;
@@ -103,7 +103,7 @@ export async function createProject(
id: data.id,
orgId,
name: data.name,
- repo: data.repo,
+ repo: data.repo ?? null,
baseBranch: data.baseBranch ?? 'main',
branchPrefix: data.branchPrefix ?? 'feature/',
model: data.model,
diff --git a/src/db/schema/projects.ts b/src/db/schema/projects.ts
index 6381ab89..b1abfa39 100644
--- a/src/db/schema/projects.ts
+++ b/src/db/schema/projects.ts
@@ -1,4 +1,4 @@
-import { boolean, numeric, pgTable, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
+import { boolean, numeric, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
import { organizations } from './organizations.js';
export const projects = pgTable(
@@ -9,7 +9,7 @@ export const projects = pgTable(
.notNull()
.references(() => organizations.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
- repo: text('repo').notNull().unique(),
+ repo: text('repo').unique(),
baseBranch: text('base_branch').default('main'),
branchPrefix: text('branch_prefix').default('feature/'),
@@ -24,5 +24,6 @@ export const projects = pgTable(
.defaultNow()
.$onUpdate(() => new Date()),
},
- (table) => [uniqueIndex('uq_projects_repo').on(table.repo)],
+ // Partial unique index (only for non-null values) defined in migration 0019
+ () => [],
);
diff --git a/src/email/client.ts b/src/email/client.ts
index 071be432..3fd1ec5b 100644
--- a/src/email/client.ts
+++ b/src/email/client.ts
@@ -43,19 +43,38 @@ export function getEmailCredentials(): EmailCredentials {
return scoped;
}
+/**
+ * Get the email address from credentials, regardless of auth method.
+ */
+export function getEmailAddress(): string {
+ const creds = getEmailCredentials();
+ return creds.authMethod === 'oauth' ? creds.email : creds.username;
+}
+
/**
* Create an ImapFlow client configured with scoped credentials.
+ * Supports both password and OAuth (XOAUTH2) authentication.
*/
function createImapClient(): ImapFlow {
const creds = getEmailCredentials();
+
+ // Build auth config based on authentication method
+ const auth =
+ creds.authMethod === 'oauth'
+ ? {
+ user: creds.email,
+ accessToken: creds.accessToken,
+ }
+ : {
+ user: creds.username,
+ pass: creds.password,
+ };
+
return new ImapFlow({
host: creds.imapHost,
port: creds.imapPort,
secure: true, // Use TLS
- auth: {
- user: creds.username,
- pass: creds.password,
- },
+ auth,
logger: false, // Suppress imapflow's built-in logging
connectionTimeout: 30000, // 30s to establish connection
greetingTimeout: 15000, // 15s to receive server greeting
@@ -65,17 +84,29 @@ function createImapClient(): ImapFlow {
/**
* Create a nodemailer transporter configured with scoped credentials.
+ * Supports both password and OAuth (XOAUTH2) authentication.
*/
function createSmtpTransport(): Transporter {
const creds = getEmailCredentials();
+
+ // Build auth config based on authentication method
+ const auth =
+ creds.authMethod === 'oauth'
+ ? {
+ type: 'OAuth2' as const,
+ user: creds.email,
+ accessToken: creds.accessToken,
+ }
+ : {
+ user: creds.username,
+ pass: creds.password,
+ };
+
return nodemailer.createTransport({
host: creds.smtpHost,
port: creds.smtpPort,
secure: creds.smtpPort === 465, // Use TLS for port 465, STARTTLS for 587
- auth: {
- user: creds.username,
- pass: creds.password,
- },
+ auth,
});
}
@@ -346,7 +377,7 @@ export async function readEmail(folder: string, uid: number): Promise {
- const creds = getEmailCredentials();
+ const fromEmail = getEmailAddress();
const transport = createSmtpTransport();
try {
@@ -356,7 +387,7 @@ export async function sendEmail(options: SendEmailOptions): Promise typeof a === 'string')
+ : [],
+ rejected: Array.isArray(result.rejected)
+ ? result.rejected.filter((r: unknown): r is string => typeof r === 'string')
+ : [],
};
} finally {
await transport.close();
@@ -381,21 +416,49 @@ export async function sendEmail(options: SendEmailOptions): Promise {
+ const client = createImapClient();
+
+ try {
+ await client.connect();
+ logger.debug('Connected to IMAP server for mark-as-seen', { folder, uid });
+
+ const lock = await client.getMailboxLock(folder);
+ try {
+ await client.messageFlagsAdd(uid, ['\\Seen'], { uid: true });
+ logger.debug('Email marked as seen', { folder, uid });
+ } catch (error) {
+ logger.error('Failed to mark email as seen', {
+ folder,
+ uid,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ throw error;
+ } finally {
+ lock.release();
+ }
+ } finally {
+ await client.logout();
+ }
+}
+
export async function replyToEmail(options: ReplyEmailOptions): Promise {
// First, fetch the original message to get threading info
const original = await readEmail(options.folder, options.uid);
+ // Get our email address for filtering and from field
+ const fromEmail = getEmailAddress();
+ const selfEmailLower = fromEmail.toLowerCase();
+
// Build recipient list
const recipients: string[] = [];
if (options.replyAll) {
// Reply to sender + all original recipients (excluding ourselves)
- const creds = getEmailCredentials();
- const selfEmail = creds.username.toLowerCase();
recipients.push(original.from);
- recipients.push(...original.to.filter((addr) => !addr.toLowerCase().includes(selfEmail)));
- recipients.push(...original.cc.filter((addr) => !addr.toLowerCase().includes(selfEmail)));
+ recipients.push(...original.to.filter((addr) => !addr.toLowerCase().includes(selfEmailLower)));
+ recipients.push(...original.cc.filter((addr) => !addr.toLowerCase().includes(selfEmailLower)));
} else {
// Reply only to sender
recipients.push(original.from);
@@ -411,7 +474,6 @@ export async function replyToEmail(options: ReplyEmailOptions): Promise typeof a === 'string')
+ : [],
+ rejected: Array.isArray(result.rejected)
+ ? result.rejected.filter((r: unknown): r is string => typeof r === 'string')
+ : [],
};
} finally {
await transport.close();
diff --git a/src/email/gmail/oauth.ts b/src/email/gmail/oauth.ts
new file mode 100644
index 00000000..39fd91b0
--- /dev/null
+++ b/src/email/gmail/oauth.ts
@@ -0,0 +1,279 @@
+/**
+ * Gmail OAuth 2.0 utilities.
+ *
+ * Provides functions for generating auth URLs, exchanging codes for tokens,
+ * and refreshing access tokens.
+ */
+
+import { logger } from '../../utils/logging.js';
+
+const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
+const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
+
+/**
+ * Gmail API scopes required for email operations.
+ * - gmail.readonly: Read emails
+ * - gmail.send: Send emails
+ * - gmail.modify: Mark as read, archive, etc.
+ */
+const GMAIL_SCOPES = [
+ 'https://mail.google.com/', // Full IMAP/SMTP access
+ 'https://www.googleapis.com/auth/userinfo.email', // Get email address
+];
+
+export interface GmailTokenResponse {
+ access_token: string;
+ expires_in: number;
+ refresh_token?: string;
+ scope: string;
+ token_type: string;
+}
+
+export interface GmailUserInfo {
+ email: string;
+ verified_email: boolean;
+}
+
+/**
+ * Generate a Google OAuth 2.0 authorization URL.
+ *
+ * @param clientId - Google OAuth client ID
+ * @param redirectUri - Callback URL after authorization
+ * @param state - CSRF protection state parameter
+ * @returns Authorization URL to redirect the user to
+ */
+export function getGmailAuthUrl(clientId: string, redirectUri: string, state: string): string {
+ const params = new URLSearchParams({
+ client_id: clientId,
+ redirect_uri: redirectUri,
+ response_type: 'code',
+ scope: GMAIL_SCOPES.join(' '),
+ access_type: 'offline', // Request refresh token
+ prompt: 'consent', // Force consent screen to get refresh token
+ state,
+ });
+
+ return `${GOOGLE_AUTH_URL}?${params.toString()}`;
+}
+
+/**
+ * Exchange an authorization code for access and refresh tokens.
+ *
+ * @param clientId - Google OAuth client ID
+ * @param clientSecret - Google OAuth client secret
+ * @param code - Authorization code from the callback
+ * @param redirectUri - Same redirect URI used in the auth URL
+ * @returns Token response containing access_token and refresh_token
+ */
+export async function exchangeGmailCode(
+ clientId: string,
+ clientSecret: string,
+ code: string,
+ redirectUri: string,
+): Promise {
+ logger.debug('Exchanging Gmail authorization code for tokens');
+
+ const response = await fetch(GOOGLE_TOKEN_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: new URLSearchParams({
+ client_id: clientId,
+ client_secret: clientSecret,
+ code,
+ grant_type: 'authorization_code',
+ redirect_uri: redirectUri,
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.text();
+ logger.error('Failed to exchange Gmail code', { status: response.status, error });
+ // Sanitize error message to avoid exposing internal details
+ const userMessage = error.includes('invalid_grant')
+ ? 'Invalid or expired authorization code. Please try again.'
+ : 'Failed to exchange authorization code. Please try again.';
+ throw new Error(userMessage);
+ }
+
+ const tokens = (await response.json()) as GmailTokenResponse;
+ logger.debug('Successfully exchanged Gmail code for tokens', {
+ hasRefreshToken: !!tokens.refresh_token,
+ expiresIn: tokens.expires_in,
+ });
+
+ return tokens;
+}
+
+/**
+ * Refresh an access token using a refresh token.
+ *
+ * @param clientId - Google OAuth client ID
+ * @param clientSecret - Google OAuth client secret
+ * @param refreshToken - Refresh token to use
+ * @returns New access token and expiry
+ */
+export async function refreshGmailAccessToken(
+ clientId: string,
+ clientSecret: string,
+ refreshToken: string,
+): Promise<{ accessToken: string; expiresAt: Date }> {
+ logger.debug('Refreshing Gmail access token');
+
+ const response = await fetch(GOOGLE_TOKEN_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: new URLSearchParams({
+ client_id: clientId,
+ client_secret: clientSecret,
+ refresh_token: refreshToken,
+ grant_type: 'refresh_token',
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.text();
+ logger.error('Failed to refresh Gmail access token', { status: response.status, error });
+ // Sanitize error message to avoid exposing internal details
+ const userMessage = error.includes('invalid_grant')
+ ? 'Refresh token is invalid or expired. Please re-authorize the account.'
+ : 'Failed to refresh access token. Please try again.';
+ throw new Error(userMessage);
+ }
+
+ const tokens = (await response.json()) as GmailTokenResponse;
+ const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
+
+ logger.debug('Successfully refreshed Gmail access token', {
+ expiresIn: tokens.expires_in,
+ });
+
+ return {
+ accessToken: tokens.access_token,
+ expiresAt,
+ };
+}
+
+/**
+ * Get the user's email address using an access token.
+ *
+ * @param accessToken - Valid Gmail access token
+ * @returns User's email address
+ */
+export async function getGmailUserInfo(accessToken: string): Promise {
+ const response = await fetch('https://www.googleapis.com/oauth2/v1/userinfo?alt=json', {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ });
+
+ if (!response.ok) {
+ const error = await response.text();
+ logger.error('Failed to get Gmail user info', { status: response.status, error });
+ throw new Error(`Failed to get user info: ${error}`);
+ }
+
+ return (await response.json()) as GmailUserInfo;
+}
+
+// ============================================================================
+// Access token cache (in-memory, per-process)
+// ============================================================================
+
+interface CachedToken {
+ accessToken: string;
+ expiresAt: Date;
+}
+
+const tokenCache = new Map();
+
+// Refresh locks to prevent concurrent token refreshes for the same email
+const refreshLocks = new Map>();
+
+// Buffer time before expiry to trigger refresh (1 minute)
+const EXPIRY_BUFFER_MS = 60 * 1000;
+
+// Maximum cache size for LRU-style eviction
+const TOKEN_CACHE_MAX_SIZE = 100;
+
+/**
+ * Add token to cache with LRU-style eviction when full.
+ */
+function cacheAccessToken(email: string, token: CachedToken): void {
+ // Evict oldest entry if at capacity
+ if (tokenCache.size >= TOKEN_CACHE_MAX_SIZE && !tokenCache.has(email)) {
+ const firstKey = tokenCache.keys().next().value;
+ if (firstKey) {
+ tokenCache.delete(firstKey);
+ logger.debug('Evicted oldest token from cache', { evictedEmail: firstKey });
+ }
+ }
+ tokenCache.set(email, token);
+}
+
+/**
+ * Get an access token for a Gmail account, using cache or refreshing as needed.
+ * Uses a refresh lock to prevent concurrent token refreshes for the same email.
+ *
+ * @param clientId - Google OAuth client ID
+ * @param clientSecret - Google OAuth client secret
+ * @param refreshToken - Refresh token for the account
+ * @param email - Gmail address (used as cache key)
+ * @returns Valid access token
+ */
+export async function getGmailAccessToken(
+ clientId: string,
+ clientSecret: string,
+ refreshToken: string,
+ email: string,
+): Promise {
+ // Check if there's already a refresh in progress for this email
+ const existingLock = refreshLocks.get(email);
+ if (existingLock) {
+ logger.debug('Waiting for existing token refresh', { email });
+ return existingLock;
+ }
+
+ // Return cached token if valid and not expiring soon
+ const cached = tokenCache.get(email);
+ if (cached && cached.expiresAt.getTime() > Date.now() + EXPIRY_BUFFER_MS) {
+ logger.debug('Using cached Gmail access token', { email });
+ return cached.accessToken;
+ }
+
+ // Create a refresh promise and store it as a lock
+ const refreshPromise = (async () => {
+ try {
+ const { accessToken, expiresAt } = await refreshGmailAccessToken(
+ clientId,
+ clientSecret,
+ refreshToken,
+ );
+
+ cacheAccessToken(email, { accessToken, expiresAt });
+ logger.debug('Cached new Gmail access token', { email, expiresAt });
+
+ return accessToken;
+ } finally {
+ // Always remove the lock when done
+ refreshLocks.delete(email);
+ }
+ })();
+
+ refreshLocks.set(email, refreshPromise);
+ return refreshPromise;
+}
+
+/**
+ * Clear the access token cache for a specific email or all emails.
+ */
+export function clearGmailTokenCache(email?: string): void {
+ if (email) {
+ tokenCache.delete(email);
+ } else {
+ tokenCache.clear();
+ }
+}
diff --git a/src/email/integration.ts b/src/email/integration.ts
index 8f19d66a..cc658810 100644
--- a/src/email/integration.ts
+++ b/src/email/integration.ts
@@ -3,47 +3,129 @@
*
* Provides withEmailIntegration() for establishing email credential scope
* similar to withPMCredentials() for PM integrations.
+ *
+ * Supports both IMAP (password) and Gmail (OAuth) authentication.
*/
-import { getIntegrationCredential } from '../config/provider.js';
+import { getIntegrationCredentialOrNull, getOrgCredential } from '../config/provider.js';
+import { getIntegrationProvider } from '../db/repositories/credentialsRepository.js';
import { logger } from '../utils/logging.js';
import { withEmailCredentials } from './client.js';
-import type { EmailCredentials } from './types.js';
+import { getGmailAccessToken } from './gmail/oauth.js';
+import type { EmailCredentials, OAuthEmailCredentials, PasswordEmailCredentials } from './types.js';
+
+// Gmail IMAP/SMTP server constants
+const GMAIL_IMAP_HOST = 'imap.gmail.com';
+const GMAIL_IMAP_PORT = 993;
+const GMAIL_SMTP_HOST = 'smtp.gmail.com';
+const GMAIL_SMTP_PORT = 465;
+
+/**
+ * Resolve IMAP password-based email credentials for a project.
+ */
+async function resolveImapCredentials(projectId: string): Promise {
+ const [imapHost, imapPortStr, smtpHost, smtpPortStr, username, password] = await Promise.all([
+ getIntegrationCredentialOrNull(projectId, 'email', 'imap_host'),
+ getIntegrationCredentialOrNull(projectId, 'email', 'imap_port'),
+ getIntegrationCredentialOrNull(projectId, 'email', 'smtp_host'),
+ getIntegrationCredentialOrNull(projectId, 'email', 'smtp_port'),
+ getIntegrationCredentialOrNull(projectId, 'email', 'username'),
+ getIntegrationCredentialOrNull(projectId, 'email', 'password'),
+ ]);
+
+ // All credentials are required for IMAP
+ if (!imapHost || !imapPortStr || !smtpHost || !smtpPortStr || !username || !password) {
+ return null;
+ }
+
+ const imapPort = Number.parseInt(imapPortStr, 10);
+ const smtpPort = Number.parseInt(smtpPortStr, 10);
+
+ if (Number.isNaN(imapPort) || Number.isNaN(smtpPort)) {
+ return null;
+ }
+
+ return {
+ authMethod: 'password',
+ imapHost,
+ imapPort,
+ smtpHost,
+ smtpPort,
+ username,
+ password,
+ };
+}
+
+/**
+ * Resolve Gmail OAuth credentials for a project.
+ * Fetches refresh token from integration credentials and exchanges for access token.
+ */
+async function resolveGmailCredentials(projectId: string): Promise {
+ // Get Gmail-specific credentials from integration
+ const [gmailEmail, refreshToken] = await Promise.all([
+ getIntegrationCredentialOrNull(projectId, 'email', 'gmail_email'),
+ getIntegrationCredentialOrNull(projectId, 'email', 'gmail_refresh_token'),
+ ]);
+
+ if (!gmailEmail || !refreshToken) {
+ logger.debug('Gmail credentials not found for project', { projectId });
+ return null;
+ }
+
+ // Get Google OAuth client credentials from org-level defaults
+ const [clientId, clientSecret] = await Promise.all([
+ getOrgCredential(projectId, 'GOOGLE_OAUTH_CLIENT_ID'),
+ getOrgCredential(projectId, 'GOOGLE_OAUTH_CLIENT_SECRET'),
+ ]);
+
+ if (!clientId || !clientSecret) {
+ logger.warn('Google OAuth client credentials not found at org level', { projectId });
+ return null;
+ }
+
+ try {
+ // Get or refresh access token
+ const accessToken = await getGmailAccessToken(clientId, clientSecret, refreshToken, gmailEmail);
+
+ return {
+ authMethod: 'oauth',
+ imapHost: GMAIL_IMAP_HOST,
+ imapPort: GMAIL_IMAP_PORT,
+ smtpHost: GMAIL_SMTP_HOST,
+ smtpPort: GMAIL_SMTP_PORT,
+ email: gmailEmail,
+ accessToken,
+ };
+ } catch (error) {
+ logger.error('Failed to get Gmail access token', {
+ projectId,
+ email: gmailEmail,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return null;
+ }
+}
/**
* Resolve email credentials for a project from the database.
+ * Automatically detects the provider (imap or gmail) and returns appropriate credentials.
*/
export async function resolveEmailCredentials(projectId: string): Promise {
try {
- const [imapHost, imapPortStr, smtpHost, smtpPortStr, username, password] = await Promise.all([
- getIntegrationCredential(projectId, 'email', 'imap_host'),
- getIntegrationCredential(projectId, 'email', 'imap_port'),
- getIntegrationCredential(projectId, 'email', 'smtp_host'),
- getIntegrationCredential(projectId, 'email', 'smtp_port'),
- getIntegrationCredential(projectId, 'email', 'username'),
- getIntegrationCredential(projectId, 'email', 'password'),
- ]);
-
- // All credentials are required
- if (!imapHost || !imapPortStr || !smtpHost || !smtpPortStr || !username || !password) {
+ // Check which email provider is configured
+ const provider = await getIntegrationProvider(projectId, 'email');
+
+ if (!provider) {
+ logger.debug('No email integration configured for project', { projectId });
return null;
}
- const imapPort = Number.parseInt(imapPortStr, 10);
- const smtpPort = Number.parseInt(smtpPortStr, 10);
-
- if (Number.isNaN(imapPort) || Number.isNaN(smtpPort)) {
- return null;
+ if (provider === 'gmail') {
+ return resolveGmailCredentials(projectId);
}
- return {
- imapHost,
- imapPort,
- smtpHost,
- smtpPort,
- username,
- password,
- };
+ // Default to IMAP password auth
+ return resolveImapCredentials(projectId);
} catch (error) {
logger.warn('Failed to resolve email credentials', {
projectId,
diff --git a/src/email/types.ts b/src/email/types.ts
index 707b8a5e..85423699 100644
--- a/src/email/types.ts
+++ b/src/email/types.ts
@@ -1,7 +1,42 @@
+/**
+ * Base email credentials for IMAP/SMTP connections.
+ */
+export interface BaseEmailCredentials {
+ imapHost: string;
+ imapPort: number;
+ smtpHost: string;
+ smtpPort: number;
+}
+
+/**
+ * Email credentials using password authentication.
+ */
+export interface PasswordEmailCredentials extends BaseEmailCredentials {
+ authMethod: 'password';
+ username: string;
+ password: string;
+}
+
+/**
+ * Email credentials using OAuth 2.0 (XOAUTH2).
+ */
+export interface OAuthEmailCredentials extends BaseEmailCredentials {
+ authMethod: 'oauth';
+ email: string;
+ accessToken: string;
+}
+
/**
* Email credentials for IMAP/SMTP connections.
+ * Supports both password and OAuth authentication.
+ */
+export type EmailCredentials = PasswordEmailCredentials | OAuthEmailCredentials;
+
+/**
+ * Legacy email credentials (password auth only).
+ * Used for backward compatibility with existing code.
*/
-export interface EmailCredentials {
+export interface LegacyEmailCredentials {
imapHost: string;
imapPort: number;
smtpHost: string;
diff --git a/src/gadgets/email/MarkEmailAsSeen.ts b/src/gadgets/email/MarkEmailAsSeen.ts
new file mode 100644
index 00000000..d00d95e5
--- /dev/null
+++ b/src/gadgets/email/MarkEmailAsSeen.ts
@@ -0,0 +1,36 @@
+import { Gadget, z } from 'llmist';
+import { markEmailAsSeen } from './core/markEmailAsSeen.js';
+
+export class MarkEmailAsSeen extends Gadget({
+ name: 'MarkEmailAsSeen',
+ description:
+ 'Mark an email as seen/read in the mailbox. Use this after processing an email to prevent re-processing on subsequent runs.',
+ timeoutMs: 15000,
+ schema: z.object({
+ folder: z
+ .string()
+ .min(1)
+ .max(100)
+ .default('INBOX')
+ .describe('Mailbox folder where the email is located (default: INBOX)'),
+ uid: z
+ .number()
+ .int()
+ .positive()
+ .describe('Unique identifier (UID) of the email to mark as seen'),
+ }),
+ examples: [
+ {
+ params: { folder: 'INBOX', uid: 456 },
+ comment: 'Mark email with UID 456 in INBOX as read',
+ },
+ {
+ params: { folder: 'INBOX', uid: 123 },
+ comment: 'Mark email with UID 123 in INBOX as read',
+ },
+ ],
+}) {
+ override async execute(params: this['params']): Promise {
+ return markEmailAsSeen(params.folder, params.uid);
+ }
+}
diff --git a/src/gadgets/email/core/markEmailAsSeen.ts b/src/gadgets/email/core/markEmailAsSeen.ts
new file mode 100644
index 00000000..325da3b3
--- /dev/null
+++ b/src/gadgets/email/core/markEmailAsSeen.ts
@@ -0,0 +1,17 @@
+import { markEmailAsSeen as markEmailAsSeenClient } from '../../../email/client.js';
+import { logger } from '../../../utils/logging.js';
+
+export async function markEmailAsSeen(folder: string, uid: number): Promise {
+ try {
+ await markEmailAsSeenClient(folder, uid);
+ return `Email (UID: ${uid}) in folder "${folder}" has been marked as seen/read.`;
+ } catch (error) {
+ logger.error('Mark email as seen failed', {
+ folder,
+ uid,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ const message = error instanceof Error ? error.message : String(error);
+ return `Error marking email as seen: ${message}`;
+ }
+}
diff --git a/src/gadgets/email/index.ts b/src/gadgets/email/index.ts
index 322c871c..45a38c98 100644
--- a/src/gadgets/email/index.ts
+++ b/src/gadgets/email/index.ts
@@ -2,3 +2,4 @@ export { SendEmail } from './SendEmail.js';
export { SearchEmails } from './SearchEmails.js';
export { ReadEmail } from './ReadEmail.js';
export { ReplyToEmail } from './ReplyToEmail.js';
+export { MarkEmailAsSeen } from './MarkEmailAsSeen.js';
diff --git a/src/router/config.ts b/src/router/config.ts
index 3254b4a9..3c9c5855 100644
--- a/src/router/config.ts
+++ b/src/router/config.ts
@@ -5,7 +5,7 @@ import type { CascadeConfig, ProjectConfig } from '../types/index.js';
// Minimal config types - what router needs for quick filtering
export interface RouterProjectConfig {
id: string;
- repo: string; // owner/repo format
+ repo?: string; // owner/repo format (optional for email-only projects)
pmType: 'trello' | 'jira';
trello?: {
boardId: string;
diff --git a/src/triggers/shared/manual-runner.ts b/src/triggers/shared/manual-runner.ts
index 2946aa15..2a100ea6 100644
--- a/src/triggers/shared/manual-runner.ts
+++ b/src/triggers/shared/manual-runner.ts
@@ -1,5 +1,7 @@
import { runAgent } from '../../agents/registry.js';
+import { parseEmailJokeTriggers } from '../../config/triggerConfig.js';
import { getRunById } from '../../db/repositories/runsRepository.js';
+import { getIntegrationByProjectAndCategory } from '../../db/repositories/settingsRepository.js';
import { withEmailIntegration } from '../../email/integration.js';
import { withPMCredentials } from '../../pm/context.js';
import { createPMProvider, pmRegistry, withPMProvider } from '../../pm/index.js';
@@ -101,6 +103,21 @@ export async function triggerManualRun(
config,
};
+ // For email-joke agent, fetch senderEmail from email integration triggers
+ if (input.agentType === 'email-joke') {
+ const emailIntegration = await getIntegrationByProjectAndCategory(input.projectId, 'email');
+ if (emailIntegration) {
+ const triggers = parseEmailJokeTriggers(emailIntegration.triggers);
+ if (triggers.senderEmail) {
+ agentInput.senderEmail = triggers.senderEmail;
+ }
+ } else {
+ logger.debug('No email integration found, skipping senderEmail injection', {
+ projectId: input.projectId,
+ });
+ }
+ }
+
try {
const pmProvider = createPMProvider(project);
const result = await withPMCredentials(
diff --git a/src/types/index.ts b/src/types/index.ts
index feda13ea..16f9a95c 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -33,6 +33,9 @@ export interface AgentInput {
triggerCommentText?: string;
triggerCommentAuthor?: string;
+ // Email-joke agent fields
+ senderEmail?: string;
+
// Interactive mode (local development)
interactive?: boolean;
// Auto-accept prompts in interactive mode
diff --git a/src/utils/repo.ts b/src/utils/repo.ts
index 9f2d5e0c..3e68195e 100644
--- a/src/utils/repo.ts
+++ b/src/utils/repo.ts
@@ -38,6 +38,9 @@ export async function cloneRepo(
targetDir: string,
token?: string,
): Promise {
+ if (!project.repo) {
+ throw new Error(`Cannot clone repository: project '${project.id}' has no repo configured`);
+ }
const cloneToken = token ?? (await getProjectGitHubToken(project));
const cloneUrl = `https://${cloneToken}@github.com/${project.repo}.git`;
diff --git a/tests/unit/agents/definitions/loader.test.ts b/tests/unit/agents/definitions/loader.test.ts
index e8ecfe37..d9ff2f8a 100644
--- a/tests/unit/agents/definitions/loader.test.ts
+++ b/tests/unit/agents/definitions/loader.test.ts
@@ -15,6 +15,7 @@ import { getAgentCapabilities } from '../../../../src/agents/shared/capabilities
const ALL_AGENT_TYPES = [
'debug',
+ 'email-joke',
'implementation',
'planning',
'respond-to-ci',
@@ -31,7 +32,7 @@ describe('YAML agent definitions loader', () => {
});
describe('getKnownAgentTypes', () => {
- it('discovers all 9 agent types from YAML files', () => {
+ it('discovers all 10 agent types from YAML files', () => {
const types = getKnownAgentTypes();
expect(types).toEqual(ALL_AGENT_TYPES);
});
@@ -64,9 +65,9 @@ describe('YAML agent definitions loader', () => {
});
describe('loadAllAgentDefinitions', () => {
- it('returns a map with all 9 agent types', () => {
+ it('returns a map with all 10 agent types', () => {
const all = loadAllAgentDefinitions();
- expect(all.size).toBe(9);
+ expect(all.size).toBe(10);
for (const agentType of ALL_AGENT_TYPES) {
expect(all.has(agentType)).toBe(true);
}
diff --git a/tests/unit/config/triggerConfig.test.ts b/tests/unit/config/triggerConfig.test.ts
index 0770a804..a7c1fc1e 100644
--- a/tests/unit/config/triggerConfig.test.ts
+++ b/tests/unit/config/triggerConfig.test.ts
@@ -1,8 +1,11 @@
import { describe, expect, it } from 'vitest';
import {
+ EmailJokeTriggerConfigSchema,
GitHubTriggerConfigSchema,
JiraTriggerConfigSchema,
TrelloTriggerConfigSchema,
+ parseEmailJokeTriggers,
+ resolveEmailJokeTriggerConfig,
resolveGitHubTriggerEnabled,
resolveIssueTransitionedEnabled,
resolveJiraTriggerEnabled,
@@ -610,3 +613,78 @@ describe('resolveTriggerEnabled', () => {
});
});
});
+
+describe('EmailJokeTriggerConfigSchema', () => {
+ it('accepts valid email address', () => {
+ const result = EmailJokeTriggerConfigSchema.parse({ senderEmail: 'test@example.com' });
+ expect(result.senderEmail).toBe('test@example.com');
+ });
+
+ it('accepts null senderEmail', () => {
+ const result = EmailJokeTriggerConfigSchema.parse({ senderEmail: null });
+ expect(result.senderEmail).toBeNull();
+ });
+
+ it('accepts undefined/missing senderEmail', () => {
+ const result = EmailJokeTriggerConfigSchema.parse({});
+ expect(result.senderEmail).toBeUndefined();
+ });
+
+ it('rejects invalid email address', () => {
+ expect(() => EmailJokeTriggerConfigSchema.parse({ senderEmail: 'not-an-email' })).toThrow();
+ });
+});
+
+describe('resolveEmailJokeTriggerConfig', () => {
+ it('returns senderEmail when provided', () => {
+ const result = resolveEmailJokeTriggerConfig({ senderEmail: 'test@example.com' });
+ expect(result.senderEmail).toBe('test@example.com');
+ });
+
+ it('returns undefined senderEmail when not provided', () => {
+ const result = resolveEmailJokeTriggerConfig(undefined);
+ expect(result.senderEmail).toBeUndefined();
+ });
+
+ it('returns undefined senderEmail for empty object', () => {
+ const result = resolveEmailJokeTriggerConfig({});
+ expect(result.senderEmail).toBeUndefined();
+ });
+
+ it('converts null senderEmail to undefined', () => {
+ const result = resolveEmailJokeTriggerConfig({ senderEmail: null });
+ expect(result.senderEmail).toBeUndefined();
+ });
+});
+
+describe('parseEmailJokeTriggers', () => {
+ it('parses valid trigger object', () => {
+ const result = parseEmailJokeTriggers({ senderEmail: 'test@example.com' });
+ expect(result.senderEmail).toBe('test@example.com');
+ });
+
+ it('returns empty config for null input', () => {
+ const result = parseEmailJokeTriggers(null);
+ expect(result.senderEmail).toBeUndefined();
+ });
+
+ it('returns empty config for undefined input', () => {
+ const result = parseEmailJokeTriggers(undefined);
+ expect(result.senderEmail).toBeUndefined();
+ });
+
+ it('returns empty config for non-object input', () => {
+ const result = parseEmailJokeTriggers('not an object');
+ expect(result.senderEmail).toBeUndefined();
+ });
+
+ it('returns empty config for invalid email', () => {
+ const result = parseEmailJokeTriggers({ senderEmail: 'invalid' });
+ expect(result.senderEmail).toBeUndefined();
+ });
+
+ it('handles null senderEmail in valid object', () => {
+ const result = parseEmailJokeTriggers({ senderEmail: null });
+ expect(result.senderEmail).toBeNull();
+ });
+});
diff --git a/tests/unit/email/integration.test.ts b/tests/unit/email/integration.test.ts
index 78535bbc..e07224ad 100644
--- a/tests/unit/email/integration.test.ts
+++ b/tests/unit/email/integration.test.ts
@@ -1,7 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../src/config/provider.js', () => ({
- getIntegrationCredential: vi.fn(),
+ getIntegrationCredentialOrNull: vi.fn(),
+ getOrgCredential: vi.fn(),
+}));
+
+vi.mock('../../../src/db/repositories/credentialsRepository.js', () => ({
+ getIntegrationProvider: vi.fn(),
}));
vi.mock('../../../src/utils/logging.js', () => ({
@@ -13,7 +18,8 @@ vi.mock('../../../src/utils/logging.js', () => ({
},
}));
-import { getIntegrationCredential } from '../../../src/config/provider.js';
+import { getIntegrationCredentialOrNull } from '../../../src/config/provider.js';
+import { getIntegrationProvider } from '../../../src/db/repositories/credentialsRepository.js';
import {
hasEmailIntegration,
resolveEmailCredentials,
@@ -28,7 +34,8 @@ describe('email integration', () => {
describe('resolveEmailCredentials', () => {
it('returns credentials when all fields are present', async () => {
- vi.mocked(getIntegrationCredential).mockImplementation(
+ vi.mocked(getIntegrationProvider).mockResolvedValue('imap');
+ vi.mocked(getIntegrationCredentialOrNull).mockImplementation(
async (_projectId, _category, role) => {
const creds: Record = {
imap_host: 'imap.example.com',
@@ -45,6 +52,7 @@ describe('email integration', () => {
const result = await resolveEmailCredentials('project-1');
expect(result).toEqual({
+ authMethod: 'password',
imapHost: 'imap.example.com',
imapPort: 993,
smtpHost: 'smtp.example.com',
@@ -55,7 +63,8 @@ describe('email integration', () => {
});
it('returns null when a credential is missing', async () => {
- vi.mocked(getIntegrationCredential).mockImplementation(
+ vi.mocked(getIntegrationProvider).mockResolvedValue('imap');
+ vi.mocked(getIntegrationCredentialOrNull).mockImplementation(
async (_projectId, _category, role) => {
if (role === 'password') return null; // Missing password
const creds: Record = {
@@ -74,7 +83,8 @@ describe('email integration', () => {
});
it('returns null when port is not a valid number', async () => {
- vi.mocked(getIntegrationCredential).mockImplementation(
+ vi.mocked(getIntegrationProvider).mockResolvedValue('imap');
+ vi.mocked(getIntegrationCredentialOrNull).mockImplementation(
async (_projectId, _category, role) => {
const creds: Record = {
imap_host: 'imap.example.com',
@@ -93,7 +103,7 @@ describe('email integration', () => {
});
it('logs warning and returns null on error', async () => {
- vi.mocked(getIntegrationCredential).mockRejectedValue(new Error('DB error'));
+ vi.mocked(getIntegrationProvider).mockRejectedValue(new Error('DB error'));
const result = await resolveEmailCredentials('project-1');
@@ -106,11 +116,19 @@ describe('email integration', () => {
}),
);
});
+
+ it('returns null when no email integration is configured', async () => {
+ vi.mocked(getIntegrationProvider).mockResolvedValue(null);
+
+ const result = await resolveEmailCredentials('project-1');
+ expect(result).toBeNull();
+ });
});
describe('withEmailIntegration', () => {
it('runs function with credentials when available', async () => {
- vi.mocked(getIntegrationCredential).mockImplementation(
+ vi.mocked(getIntegrationProvider).mockResolvedValue('imap');
+ vi.mocked(getIntegrationCredentialOrNull).mockImplementation(
async (_projectId, _category, role) => {
const creds: Record = {
imap_host: 'imap.example.com',
@@ -132,7 +150,7 @@ describe('email integration', () => {
});
it('runs function without credentials when not configured', async () => {
- vi.mocked(getIntegrationCredential).mockResolvedValue(null);
+ vi.mocked(getIntegrationProvider).mockResolvedValue(null);
const fn = vi.fn().mockResolvedValue('result');
const result = await withEmailIntegration('project-1', fn);
@@ -144,7 +162,8 @@ describe('email integration', () => {
describe('hasEmailIntegration', () => {
it('returns true when credentials are configured', async () => {
- vi.mocked(getIntegrationCredential).mockImplementation(
+ vi.mocked(getIntegrationProvider).mockResolvedValue('imap');
+ vi.mocked(getIntegrationCredentialOrNull).mockImplementation(
async (_projectId, _category, role) => {
const creds: Record = {
imap_host: 'imap.example.com',
@@ -163,7 +182,7 @@ describe('email integration', () => {
});
it('returns false when credentials are not configured', async () => {
- vi.mocked(getIntegrationCredential).mockResolvedValue(null);
+ vi.mocked(getIntegrationProvider).mockResolvedValue(null);
const result = await hasEmailIntegration('project-1');
expect(result).toBe(false);
diff --git a/tests/unit/gadgets/email/core.test.ts b/tests/unit/gadgets/email/core.test.ts
index 25172744..f4154c8f 100644
--- a/tests/unit/gadgets/email/core.test.ts
+++ b/tests/unit/gadgets/email/core.test.ts
@@ -5,6 +5,7 @@ vi.mock('../../../../src/email/client.js', () => ({
searchEmails: vi.fn(),
readEmail: vi.fn(),
replyToEmail: vi.fn(),
+ markEmailAsSeen: vi.fn(),
}));
vi.mock('../../../../src/utils/logging.js', () => ({
@@ -17,11 +18,13 @@ vi.mock('../../../../src/utils/logging.js', () => ({
}));
import {
+ markEmailAsSeen as markEmailAsSeenClient,
readEmail as readEmailClient,
replyToEmail as replyToEmailClient,
searchEmails as searchEmailsClient,
sendEmail as sendEmailClient,
} from '../../../../src/email/client.js';
+import { markEmailAsSeen } from '../../../../src/gadgets/email/core/markEmailAsSeen.js';
import { readEmail } from '../../../../src/gadgets/email/core/readEmail.js';
import { replyToEmail } from '../../../../src/gadgets/email/core/replyToEmail.js';
import { searchEmails } from '../../../../src/gadgets/email/core/searchEmails.js';
@@ -289,4 +292,31 @@ describe('email gadget core functions', () => {
);
});
});
+
+ describe('markEmailAsSeen', () => {
+ it('returns success message when email is marked as seen', async () => {
+ vi.mocked(markEmailAsSeenClient).mockResolvedValue(undefined);
+
+ const result = await markEmailAsSeen('INBOX', 456);
+
+ expect(result).toBe('Email (UID: 456) in folder "INBOX" has been marked as seen/read.');
+ expect(markEmailAsSeenClient).toHaveBeenCalledWith('INBOX', 456);
+ });
+
+ it('returns error message and logs on failure', async () => {
+ vi.mocked(markEmailAsSeenClient).mockRejectedValue(new Error('IMAP flag error'));
+
+ const result = await markEmailAsSeen('INBOX', 789);
+
+ expect(result).toBe('Error marking email as seen: IMAP flag error');
+ expect(logger.error).toHaveBeenCalledWith(
+ 'Mark email as seen failed',
+ expect.objectContaining({
+ folder: 'INBOX',
+ uid: 789,
+ error: 'IMAP flag error',
+ }),
+ );
+ });
+ });
});
diff --git a/tools/setup-webhooks.ts b/tools/setup-webhooks.ts
index b0e658f8..8ec13a23 100644
--- a/tools/setup-webhooks.ts
+++ b/tools/setup-webhooks.ts
@@ -57,7 +57,7 @@ function hasFlag(args: string[], flag: string): boolean {
interface ProjectContext {
projectId: string;
orgId: string;
- repo: string;
+ repo: string | null;
boardId: string;
trelloApiKey: string;
trelloToken: string;
@@ -101,7 +101,7 @@ async function resolveProjectContext(projectId: string): Promise
return {
projectId,
orgId: project.orgId,
- repo: project.repo,
+ repo: project.repo ?? null,
boardId: project.trello.boardId,
trelloApiKey: trelloApiKey ?? '',
trelloToken: trelloToken ?? '',
@@ -176,6 +176,10 @@ interface GitHubWebhook {
}
async function githubListWebhooks(ctx: ProjectContext): Promise {
+ if (!ctx.repo) {
+ console.warn('Skipping GitHub listWebhooks: no repo configured for this project');
+ return [];
+ }
const octokit = getOctokit(ctx.githubToken);
const { owner, repo } = parseRepo(ctx.repo);
const { data } = await octokit.repos.listWebhooks({ owner, repo });
@@ -185,7 +189,11 @@ async function githubListWebhooks(ctx: ProjectContext): Promise
async function githubCreateWebhook(
ctx: ProjectContext,
callbackURL: string,
-): Promise {
+): Promise {
+ if (!ctx.repo) {
+ console.warn('Skipping GitHub createWebhook: no repo configured for this project');
+ return null;
+ }
const octokit = getOctokit(ctx.githubToken);
const { owner, repo } = parseRepo(ctx.repo);
const { data } = await octokit.repos.createWebhook({
@@ -202,6 +210,10 @@ async function githubCreateWebhook(
}
async function githubDeleteWebhook(ctx: ProjectContext, hookId: number): Promise {
+ if (!ctx.repo) {
+ console.warn('Skipping GitHub deleteWebhook: no repo configured for this project');
+ return;
+ }
const octokit = getOctokit(ctx.githubToken);
const { owner, repo } = parseRepo(ctx.repo);
await octokit.repos.deleteWebhook({ owner, repo, hook_id: hookId });
@@ -251,7 +263,7 @@ async function handleList(args: string[]): Promise {
const ctx = await resolveProjectContext(projectId);
console.log(`Project: ${ctx.projectId} (org: ${ctx.orgId})`);
- console.log(`Repo: ${ctx.repo}`);
+ console.log(`Repo: ${ctx.repo ?? '(none - email-only project)'}`);
console.log(`Trello board: ${ctx.boardId}`);
console.log('');
@@ -259,8 +271,45 @@ async function handleList(args: string[]): Promise {
printTrelloWebhooks(await trelloListWebhooks(ctx));
}
- if (!trelloOnly && ctx.githubToken) {
+ if (!trelloOnly && ctx.githubToken && ctx.repo) {
printGitHubWebhooks(await githubListWebhooks(ctx));
+ } else if (!trelloOnly && !ctx.repo) {
+ console.log('GitHub webhooks: (skipped - no repo configured)');
+ console.log('');
+ }
+}
+
+async function createTrelloWebhookIfNeeded(
+ ctx: ProjectContext,
+ callbackUrl: string,
+): Promise {
+ const existing = await trelloListWebhooks(ctx);
+ const duplicate = existing.find((w) => w.callbackURL === callbackUrl);
+
+ if (duplicate) {
+ console.log(`Trello webhook already exists: [${duplicate.id}] ${duplicate.callbackURL}`);
+ } else {
+ const created = await trelloCreateWebhook(ctx, callbackUrl);
+ console.log(`Created Trello webhook: [${created.id}] ${created.callbackURL}`);
+ }
+}
+
+async function createGitHubWebhookIfNeeded(
+ ctx: ProjectContext,
+ callbackUrl: string,
+): Promise {
+ const existing = await githubListWebhooks(ctx);
+ const duplicate = existing.find((w) => w.config.url === callbackUrl);
+
+ if (duplicate) {
+ console.log(`GitHub webhook already exists: [${duplicate.id}] ${duplicate.config.url}`);
+ } else {
+ const created = await githubCreateWebhook(ctx, callbackUrl);
+ if (created) {
+ console.log(
+ `Created GitHub webhook: [${created.id}] ${created.config.url} (events: ${created.events.join(', ')})`,
+ );
+ }
}
}
@@ -279,32 +328,14 @@ async function handleCreate(args: string[]): Promise {
// Trello webhook
if (!githubOnly && ctx.trelloApiKey && ctx.trelloToken) {
- const trelloCallbackUrl = `${baseUrl}/webhook/trello`;
- const existing = await trelloListWebhooks(ctx);
- const duplicate = existing.find((w) => w.callbackURL === trelloCallbackUrl);
-
- if (duplicate) {
- console.log(`Trello webhook already exists: [${duplicate.id}] ${duplicate.callbackURL}`);
- } else {
- const created = await trelloCreateWebhook(ctx, trelloCallbackUrl);
- console.log(`Created Trello webhook: [${created.id}] ${created.callbackURL}`);
- }
+ await createTrelloWebhookIfNeeded(ctx, `${baseUrl}/webhook/trello`);
}
// GitHub webhook
- if (!trelloOnly && ctx.githubToken) {
- const githubCallbackUrl = `${baseUrl}/webhook/github`;
- const existing = await githubListWebhooks(ctx);
- const duplicate = existing.find((w) => w.config.url === githubCallbackUrl);
-
- if (duplicate) {
- console.log(`GitHub webhook already exists: [${duplicate.id}] ${duplicate.config.url}`);
- } else {
- const created = await githubCreateWebhook(ctx, githubCallbackUrl);
- console.log(
- `Created GitHub webhook: [${created.id}] ${created.config.url} (events: ${created.events.join(', ')})`,
- );
- }
+ if (!trelloOnly && ctx.githubToken && ctx.repo) {
+ await createGitHubWebhookIfNeeded(ctx, `${baseUrl}/webhook/github`);
+ } else if (!trelloOnly && !ctx.repo) {
+ console.log('Skipping GitHub webhook: no repo configured for this project');
}
}
@@ -353,8 +384,10 @@ async function handleDelete(args: string[]): Promise {
await deleteTrelloWebhooksForUrl(ctx, `${baseUrl}/webhook/trello`);
}
- if (!trelloOnly && ctx.githubToken) {
+ if (!trelloOnly && ctx.githubToken && ctx.repo) {
await deleteGitHubWebhooksForUrl(ctx, `${baseUrl}/webhook/github`);
+ } else if (!trelloOnly && !ctx.repo) {
+ console.log('Skipping GitHub webhook deletion: no repo configured for this project');
}
}
diff --git a/tools/test-email-integration.ts b/tools/test-email-integration.ts
new file mode 100644
index 00000000..62fea1b8
--- /dev/null
+++ b/tools/test-email-integration.ts
@@ -0,0 +1,214 @@
+#!/usr/bin/env npx tsx
+/**
+ * Test script for email integration with Gmail
+ *
+ * Usage:
+ * npx tsx tools/test-email-integration.ts --username your@gmail.com --password "your-app-password"
+ *
+ * Prerequisites:
+ * 1. Enable 2FA on your Google account
+ * 2. Generate an App Password: https://myaccount.google.com/apppasswords
+ * 3. Use the 16-character app password (no spaces) as --password
+ *
+ * What it tests:
+ * 1. IMAP connection (search emails)
+ * 2. Read a specific email
+ * 3. Send a test email (to yourself)
+ * 4. Search for the sent email
+ * 5. Reply to the email
+ */
+
+import { parseArgs } from 'node:util';
+import {
+ type EmailCredentials,
+ readEmail,
+ replyToEmail,
+ searchEmails,
+ sendEmail,
+ withEmailCredentials,
+} from '../src/email/client.js';
+import type { EmailSummary } from '../src/email/types.js';
+
+const { values } = parseArgs({
+ options: {
+ username: { type: 'string', short: 'u' },
+ password: { type: 'string', short: 'p' },
+ 'skip-send': { type: 'boolean', default: false },
+ help: { type: 'boolean', short: 'h' },
+ },
+});
+
+if (values.help) {
+ console.log(`
+Email Integration Test Script
+
+Usage:
+ npx tsx tools/test-email-integration.ts --username --password
+
+Options:
+ -u, --username Gmail address (e.g., you@gmail.com)
+ -p, --password Gmail App Password (16 chars, no spaces)
+ --skip-send Skip send/reply tests (read-only mode)
+ -h, --help Show this help
+
+Prerequisites:
+ 1. Enable 2FA on your Google account
+ 2. Generate an App Password: https://myaccount.google.com/apppasswords
+ 3. Use the 16-character app password (spaces removed)
+`);
+ process.exit(0);
+}
+
+if (!values.username || !values.password) {
+ console.error('Error: --username and --password are required');
+ console.error('Run with --help for usage');
+ process.exit(1);
+}
+
+const credentials: EmailCredentials = {
+ authMethod: 'password',
+ imapHost: 'imap.gmail.com',
+ imapPort: 993,
+ smtpHost: 'smtp.gmail.com',
+ smtpPort: 587,
+ username: values.username,
+ password: values.password,
+};
+
+function getRecentDateString(): string {
+ const since = new Date();
+ since.setDate(since.getDate() - 7);
+ return since.toISOString().split('T')[0];
+}
+
+function extractUid(results: EmailSummary[]): number | null {
+ return results.length > 0 ? results[0].uid : null;
+}
+
+async function runTest(name: string, fn: () => Promise): Promise {
+ console.log(name);
+ console.log('-'.repeat(60));
+ try {
+ await fn();
+ } catch (err) {
+ console.error('FAILED:', err instanceof Error ? err.message : err);
+ }
+ console.log();
+}
+
+async function testSearchRecentEmails(): Promise {
+ const results = await searchEmails('INBOX', { since: getRecentDateString() }, 5);
+ for (const email of results) {
+ console.log(
+ `[UID:${email.uid}] ${email.date.toISOString()} - ${email.from} - ${email.subject}`,
+ );
+ }
+ return results;
+}
+
+async function testReadFirstEmail(): Promise {
+ const results = await searchEmails('INBOX', { since: getRecentDateString() }, 1);
+ const firstUid = extractUid(results);
+ if (firstUid) {
+ console.log(`Reading email UID: ${firstUid}`);
+ const email = await readEmail('INBOX', firstUid);
+ console.log(email);
+ } else {
+ console.log('No emails found to read');
+ }
+ return firstUid;
+}
+
+async function testSendEmail(toAddress: string): Promise {
+ const result = await sendEmail({
+ to: [toAddress],
+ subject: `CASCADE Email Test - ${new Date().toISOString()}`,
+ body: `This is a test email from CASCADE email integration.
+
+Sent at: ${new Date().toISOString()}
+
+If you receive this, the SMTP integration is working correctly.`,
+ });
+ console.log(result);
+}
+
+async function testSearchSentEmail(): Promise {
+ const results = await searchEmails('INBOX', { subject: 'CASCADE Email Test' }, 5);
+ for (const email of results) {
+ console.log(
+ `[UID:${email.uid}] ${email.date.toISOString()} - ${email.from} - ${email.subject}`,
+ );
+ }
+ return extractUid(results);
+}
+
+async function testReplyToEmail(uid: number): Promise {
+ const result = await replyToEmail({
+ folder: 'INBOX',
+ uid,
+ body: `This is an automated reply from CASCADE.
+
+The reply functionality is working correctly.
+
+Original email UID: ${uid}`,
+ replyAll: false,
+ });
+ console.log(result);
+}
+
+async function runAllTests(): Promise {
+ await runTest('TEST 1: Search recent emails (INBOX, last 7 days)', testSearchRecentEmails);
+
+ await runTest('TEST 2: Read first email from search', async () => {
+ await testReadFirstEmail();
+ });
+
+ if (values['skip-send']) {
+ console.log('SKIPPED: Send/reply tests (--skip-send flag)');
+ console.log();
+ return;
+ }
+
+ await runTest('TEST 3: Send test email to yourself', async () => {
+ await testSendEmail(credentials.username);
+ });
+
+ console.log('Waiting 5 seconds for email to arrive...');
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+ console.log();
+
+ let sentUid: number | null = null;
+ await runTest('TEST 4: Search for sent email', async () => {
+ sentUid = await testSearchSentEmail();
+ });
+
+ await runTest('TEST 5: Reply to the test email', async () => {
+ if (sentUid) {
+ await testReplyToEmail(sentUid);
+ } else {
+ console.log('SKIPPED: No email UID available to reply to');
+ }
+ });
+}
+
+async function main(): Promise {
+ console.log('='.repeat(60));
+ console.log('Email Integration Test');
+ console.log('='.repeat(60));
+ console.log(`Username: ${credentials.username}`);
+ console.log(`IMAP: ${credentials.imapHost}:${credentials.imapPort}`);
+ console.log(`SMTP: ${credentials.smtpHost}:${credentials.smtpPort}`);
+ console.log('='.repeat(60));
+ console.log();
+
+ await withEmailCredentials(credentials, runAllTests);
+
+ console.log('='.repeat(60));
+ console.log('Test complete!');
+ console.log('='.repeat(60));
+}
+
+main().catch((err) => {
+ console.error('Fatal error:', err);
+ process.exit(1);
+});
diff --git a/web/src/components/projects/email-wizard.tsx b/web/src/components/projects/email-wizard.tsx
new file mode 100644
index 00000000..c68d33fd
--- /dev/null
+++ b/web/src/components/projects/email-wizard.tsx
@@ -0,0 +1,1057 @@
+import { Input } from '@/components/ui/input.js';
+import { Label } from '@/components/ui/label.js';
+import { trpc, trpcClient } from '@/lib/trpc.js';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import {
+ AlertCircle,
+ Check,
+ CheckCircle,
+ ChevronDown,
+ ChevronRight,
+ Loader2,
+ Mail,
+ Plus,
+ XCircle,
+} from 'lucide-react';
+import { useCallback, useEffect, useReducer, useState } from 'react';
+
+// ============================================================================
+// Types
+// ============================================================================
+
+interface CredentialOption {
+ id: number;
+ name: string;
+ envVarKey: string;
+ value: string;
+}
+
+type Provider = 'gmail' | 'imap';
+
+interface WizardState {
+ provider: Provider;
+ gmailEmail: string | null;
+ oauthComplete: boolean;
+ imapHostCredentialId: number | null;
+ imapPortCredentialId: number | null;
+ smtpHostCredentialId: number | null;
+ smtpPortCredentialId: number | null;
+ usernameCredentialId: number | null;
+ passwordCredentialId: number | null;
+ verificationEmail: string | null;
+ verifyError: string | null;
+ isEditing: boolean;
+}
+
+type WizardAction =
+ | { type: 'SET_PROVIDER'; provider: Provider }
+ | { type: 'SET_GMAIL_EMAIL'; email: string | null }
+ | { type: 'SET_OAUTH_COMPLETE'; complete: boolean }
+ | { type: 'SET_IMAP_HOST_CRED'; id: number | null }
+ | { type: 'SET_IMAP_PORT_CRED'; id: number | null }
+ | { type: 'SET_SMTP_HOST_CRED'; id: number | null }
+ | { type: 'SET_SMTP_PORT_CRED'; id: number | null }
+ | { type: 'SET_USERNAME_CRED'; id: number | null }
+ | { type: 'SET_PASSWORD_CRED'; id: number | null }
+ | { type: 'SET_VERIFICATION'; email: string | null; error?: string | null }
+ | { type: 'INIT_EDIT'; state: Partial };
+
+function createInitialState(): WizardState {
+ return {
+ provider: 'gmail',
+ gmailEmail: null,
+ oauthComplete: false,
+ imapHostCredentialId: null,
+ imapPortCredentialId: null,
+ smtpHostCredentialId: null,
+ smtpPortCredentialId: null,
+ usernameCredentialId: null,
+ passwordCredentialId: null,
+ verificationEmail: null,
+ verifyError: null,
+ isEditing: false,
+ };
+}
+
+function wizardReducer(state: WizardState, action: WizardAction): WizardState {
+ switch (action.type) {
+ case 'SET_PROVIDER':
+ return { ...createInitialState(), provider: action.provider };
+ case 'SET_GMAIL_EMAIL':
+ return { ...state, gmailEmail: action.email };
+ case 'SET_OAUTH_COMPLETE':
+ return { ...state, oauthComplete: action.complete };
+ case 'SET_IMAP_HOST_CRED':
+ return { ...state, imapHostCredentialId: action.id };
+ case 'SET_IMAP_PORT_CRED':
+ return { ...state, imapPortCredentialId: action.id };
+ case 'SET_SMTP_HOST_CRED':
+ return { ...state, smtpHostCredentialId: action.id };
+ case 'SET_SMTP_PORT_CRED':
+ return { ...state, smtpPortCredentialId: action.id };
+ case 'SET_USERNAME_CRED':
+ return { ...state, usernameCredentialId: action.id };
+ case 'SET_PASSWORD_CRED':
+ return { ...state, passwordCredentialId: action.id };
+ case 'SET_VERIFICATION':
+ return { ...state, verificationEmail: action.email, verifyError: action.error ?? null };
+ case 'INIT_EDIT':
+ return { ...state, ...action.state, isEditing: true };
+ default:
+ return state;
+ }
+}
+
+// ============================================================================
+// Wizard Step Shell
+// ============================================================================
+
+const STEP_TITLES = ['Provider', 'Connect', 'Verify', 'Save'] as const;
+
+function WizardStep({
+ stepNumber,
+ title,
+ status,
+ isOpen,
+ onToggle,
+ children,
+}: {
+ stepNumber: number;
+ title: string;
+ status: 'pending' | 'complete' | 'active';
+ isOpen: boolean;
+ onToggle: () => void;
+ children: React.ReactNode;
+}) {
+ const statusClasses =
+ status === 'complete'
+ ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
+ : status === 'active'
+ ? 'bg-primary text-primary-foreground'
+ : 'bg-muted text-muted-foreground';
+
+ return (
+
+
+
+ {status === 'complete' ? : stepNumber}
+
+ {title}
+ {isOpen ? (
+
+ ) : (
+
+ )}
+
+ {isOpen &&
{children}
}
+
+ );
+}
+
+// ============================================================================
+// Inline Credential Creator
+// ============================================================================
+
+function InlineCredentialCreator({
+ onCreated,
+ suggestedKey,
+}: {
+ onCreated: (id: number) => void;
+ suggestedKey?: string;
+}) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [name, setName] = useState('');
+ const [envVarKey, setEnvVarKey] = useState(suggestedKey ?? '');
+ const [value, setValue] = useState('');
+ const queryClient = useQueryClient();
+
+ const createMutation = useMutation({
+ mutationFn: () =>
+ trpcClient.credentials.create.mutate({ name, envVarKey, value, isDefault: false }),
+ onSuccess: async (result) => {
+ await queryClient.invalidateQueries({
+ queryKey: trpc.credentials.list.queryOptions().queryKey,
+ });
+ onCreated((result as { id: number }).id);
+ setIsOpen(false);
+ setName('');
+ setEnvVarKey(suggestedKey ?? '');
+ setValue('');
+ },
+ });
+
+ if (!isOpen) {
+ return (
+ setIsOpen(true)}
+ className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
+ >
+ Create new
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+// ============================================================================
+// Credential Select Component
+// ============================================================================
+
+function CredentialSelect({
+ label,
+ value,
+ onChange,
+ credentials,
+ suggestedKey,
+}: {
+ label: string;
+ value: number | null;
+ onChange: (id: number | null) => void;
+ credentials: CredentialOption[];
+ suggestedKey: string;
+}) {
+ return (
+
+ {label}
+ onChange(e.target.value ? Number(e.target.value) : null)}
+ className="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
+ >
+ Select credential...
+ {credentials.map((c) => (
+
+ {c.name}
+
+ ))}
+
+
+
+ );
+}
+
+// ============================================================================
+// Step Content Components
+// ============================================================================
+
+function GmailConnectContent({
+ hasGoogleOAuthCreds,
+ oauthComplete,
+ gmailEmail,
+ onConnect,
+ isConnecting,
+ error,
+}: {
+ hasGoogleOAuthCreds: boolean;
+ oauthComplete: boolean;
+ gmailEmail: string | null;
+ onConnect: () => void;
+ isConnecting: boolean;
+ error?: string | null;
+}) {
+ if (!hasGoogleOAuthCreds) {
+ return (
+
+
+
+
+
+ Google OAuth not configured
+
+
+ Add{' '}
+
+ GOOGLE_OAUTH_CLIENT_ID
+ {' '}
+ and{' '}
+
+ GOOGLE_OAUTH_CLIENT_SECRET
+ {' '}
+ credentials in Settings to enable Gmail OAuth.
+
+
+
+
+ );
+ }
+
+ if (oauthComplete) {
+ return (
+
+
+ Connected as {gmailEmail}
+
+ );
+ }
+
+ return (
+
+
+ Click below to authorize CASCADE to access your Gmail account.
+
+
+ {isConnecting ? : }
+ Connect Gmail
+
+ {error &&
{error}
}
+
+ );
+}
+
+function ImapConnectContent({
+ state,
+ dispatch,
+ credentials,
+}: {
+ state: WizardState;
+ dispatch: React.Dispatch;
+ credentials: CredentialOption[];
+}) {
+ return (
+
+
+ Configure IMAP and SMTP credentials for email access.
+
+
+ dispatch({ type: 'SET_IMAP_HOST_CRED', id })}
+ credentials={credentials}
+ suggestedKey="EMAIL_IMAP_HOST"
+ />
+ dispatch({ type: 'SET_IMAP_PORT_CRED', id })}
+ credentials={credentials}
+ suggestedKey="EMAIL_IMAP_PORT"
+ />
+ dispatch({ type: 'SET_SMTP_HOST_CRED', id })}
+ credentials={credentials}
+ suggestedKey="EMAIL_SMTP_HOST"
+ />
+ dispatch({ type: 'SET_SMTP_PORT_CRED', id })}
+ credentials={credentials}
+ suggestedKey="EMAIL_SMTP_PORT"
+ />
+ dispatch({ type: 'SET_USERNAME_CRED', id })}
+ credentials={credentials}
+ suggestedKey="EMAIL_USERNAME"
+ />
+ dispatch({ type: 'SET_PASSWORD_CRED', id })}
+ credentials={credentials}
+ suggestedKey="EMAIL_PASSWORD"
+ />
+
+
+ );
+}
+
+function GmailVerifyContent({
+ oauthComplete,
+ gmailEmail,
+ verificationEmail,
+ onConfirm,
+}: {
+ oauthComplete: boolean;
+ gmailEmail: string | null;
+ verificationEmail: string | null;
+ onConfirm: () => void;
+}) {
+ if (!oauthComplete) {
+ return (
+
+ Complete Gmail OAuth in the previous step first.
+
+ );
+ }
+
+ return (
+ <>
+
+
+ Gmail connection verified for {gmailEmail}
+
+ {!verificationEmail && (
+
+
+ Confirm
+
+ )}
+ >
+ );
+}
+
+function ImapVerifyContent({
+ verificationEmail,
+ verifyError,
+ imapCredsReady,
+ isVerifying,
+ onVerify,
+}: {
+ verificationEmail: string | null;
+ verifyError: string | null;
+ imapCredsReady: boolean;
+ isVerifying: boolean;
+ onVerify: () => void;
+}) {
+ return (
+
+ {verificationEmail ? (
+
+
+ IMAP connection verified for {verificationEmail}
+
+ ) : (
+ <>
+
+ Test the IMAP connection to verify credentials work.
+
+
+ {isVerifying ? (
+
+ ) : (
+
+ )}
+ Verify Connection
+
+ >
+ )}
+ {verifyError && (
+
+
+ {verifyError}
+
+ )}
+
+ );
+}
+
+// ============================================================================
+// Main EmailWizard Component
+// ============================================================================
+
+export function EmailWizard({
+ projectId,
+ initialProvider,
+ initialCredentials,
+}: {
+ projectId: string;
+ initialProvider?: string;
+ initialCredentials?: Map;
+}) {
+ const queryClient = useQueryClient();
+ const credentialsQuery = useQuery(trpc.credentials.list.queryOptions());
+ const orgCredentials = (credentialsQuery.data ?? []) as CredentialOption[];
+
+ const [state, dispatch] = useReducer(wizardReducer, undefined, createInitialState);
+ const [openSteps, setOpenSteps] = useState>(new Set([1]));
+
+ const googleClientIdCred = orgCredentials.find((c) => c.envVarKey === 'GOOGLE_OAUTH_CLIENT_ID');
+ const googleClientSecretCred = orgCredentials.find(
+ (c) => c.envVarKey === 'GOOGLE_OAUTH_CLIENT_SECRET',
+ );
+ const hasGoogleOAuthCreds = !!(googleClientIdCred && googleClientSecretCred);
+
+ // Initialize from existing integration
+ useEffect(() => {
+ if (!initialProvider || !initialCredentials) return;
+ const editState: Partial = { provider: initialProvider as Provider };
+ if (initialProvider === 'gmail') {
+ editState.oauthComplete = true;
+ } else if (initialProvider === 'imap') {
+ editState.imapHostCredentialId = initialCredentials.get('imap_host') ?? null;
+ editState.imapPortCredentialId = initialCredentials.get('imap_port') ?? null;
+ editState.smtpHostCredentialId = initialCredentials.get('smtp_host') ?? null;
+ editState.smtpPortCredentialId = initialCredentials.get('smtp_port') ?? null;
+ editState.usernameCredentialId = initialCredentials.get('username') ?? null;
+ editState.passwordCredentialId = initialCredentials.get('password') ?? null;
+ }
+ dispatch({ type: 'INIT_EDIT', state: editState });
+ setOpenSteps(new Set([1, 2, 3, 4]));
+ }, [initialProvider, initialCredentials]);
+
+ const toggleStep = (step: number) => {
+ setOpenSteps((prev) => {
+ const next = new Set(prev);
+ if (next.has(step)) next.delete(step);
+ else next.add(step);
+ return next;
+ });
+ };
+
+ const advanceToStep = useCallback((step: number) => {
+ setOpenSteps((prev) => new Set([...prev, step]));
+ }, []);
+
+ // Step status
+ const step1Complete = !!state.provider;
+ const imapCredsReady =
+ state.provider === 'imap' &&
+ !!state.imapHostCredentialId &&
+ !!state.imapPortCredentialId &&
+ !!state.smtpHostCredentialId &&
+ !!state.smtpPortCredentialId &&
+ !!state.usernameCredentialId &&
+ !!state.passwordCredentialId;
+ const step2Complete = (state.provider === 'gmail' && state.oauthComplete) || imapCredsReady;
+ const step3Complete = !!state.verificationEmail;
+
+ const getStatus = (stepNum: number, complete: boolean): 'pending' | 'complete' | 'active' => {
+ if (complete) return 'complete';
+ if (openSteps.has(stepNum)) return 'active';
+ return 'pending';
+ };
+
+ // Gmail OAuth
+ const getOAuthUrlMutation = useMutation({
+ mutationFn: async () => {
+ if (!googleClientIdCred) throw new Error('Google OAuth Client ID not configured');
+ const redirectUri = `${window.location.origin}/oauth/gmail/callback`;
+ return trpcClient.integrationsDiscovery.gmailOAuthUrl.mutate({
+ clientIdCredentialId: googleClientIdCred.id,
+ redirectUri,
+ projectId,
+ });
+ },
+ });
+
+ // OAuth popup timeout (5 minutes)
+ const OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
+
+ const handleGmailConnect = useCallback(async () => {
+ try {
+ const result = await getOAuthUrlMutation.mutateAsync();
+ const popup = window.open(result.url, 'gmail-oauth', 'width=500,height=600,popup=yes');
+
+ // Check if popup was blocked
+ if (!popup || popup.closed) {
+ dispatch({
+ type: 'SET_VERIFICATION',
+ email: null,
+ error: 'Popup blocked. Please allow popups and try again.',
+ });
+ return;
+ }
+
+ let timeoutId: ReturnType | null = null;
+ let messageHandler: ((event: MessageEvent) => void) | null = null;
+
+ const cleanup = () => {
+ if (messageHandler) {
+ window.removeEventListener('message', messageHandler);
+ messageHandler = null;
+ }
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ timeoutId = null;
+ }
+ };
+
+ messageHandler = (event: MessageEvent) => {
+ if (event.origin !== window.location.origin) return;
+ if (event.data?.type === 'gmail-oauth-complete') {
+ dispatch({ type: 'SET_GMAIL_EMAIL', email: event.data.email });
+ dispatch({ type: 'SET_OAUTH_COMPLETE', complete: true });
+ advanceToStep(3);
+ cleanup();
+ popup?.close();
+ } else if (event.data?.type === 'gmail-oauth-error') {
+ dispatch({ type: 'SET_VERIFICATION', email: null, error: event.data.error });
+ cleanup();
+ popup?.close();
+ }
+ };
+
+ window.addEventListener('message', messageHandler);
+
+ // Set timeout for abandoned OAuth flows
+ timeoutId = setTimeout(() => {
+ cleanup();
+ if (popup && !popup.closed) {
+ popup.close();
+ }
+ dispatch({
+ type: 'SET_VERIFICATION',
+ email: null,
+ error: 'OAuth timed out. Please try again.',
+ });
+ }, OAUTH_TIMEOUT_MS);
+ } catch {
+ // Error handled by mutation
+ }
+ }, [getOAuthUrlMutation, advanceToStep]);
+
+ // IMAP Verification
+ const verifyImapMutation = useMutation({
+ mutationFn: async () => {
+ if (
+ !state.imapHostCredentialId ||
+ !state.imapPortCredentialId ||
+ !state.usernameCredentialId ||
+ !state.passwordCredentialId
+ ) {
+ throw new Error('All IMAP credentials are required');
+ }
+ return trpcClient.integrationsDiscovery.verifyImap.mutate({
+ hostCredentialId: state.imapHostCredentialId,
+ portCredentialId: state.imapPortCredentialId,
+ usernameCredentialId: state.usernameCredentialId,
+ passwordCredentialId: state.passwordCredentialId,
+ });
+ },
+ onSuccess: (result) => {
+ dispatch({ type: 'SET_VERIFICATION', email: result.email });
+ advanceToStep(4);
+ },
+ onError: (err) => {
+ dispatch({
+ type: 'SET_VERIFICATION',
+ email: null,
+ error: err instanceof Error ? err.message : String(err),
+ });
+ },
+ });
+
+ // Save
+ const saveMutation = useMutation({
+ mutationFn: async () => {
+ await trpcClient.projects.integrations.upsert.mutate({
+ projectId,
+ category: 'email',
+ provider: state.provider,
+ config: {},
+ });
+ if (state.provider === 'imap') {
+ const credPairs = [
+ { role: 'imap_host', credentialId: state.imapHostCredentialId },
+ { role: 'imap_port', credentialId: state.imapPortCredentialId },
+ { role: 'smtp_host', credentialId: state.smtpHostCredentialId },
+ { role: 'smtp_port', credentialId: state.smtpPortCredentialId },
+ { role: 'username', credentialId: state.usernameCredentialId },
+ { role: 'password', credentialId: state.passwordCredentialId },
+ ].filter((p): p is { role: string; credentialId: number } => p.credentialId !== null);
+ for (const { role, credentialId } of credPairs) {
+ await trpcClient.projects.integrationCredentials.set.mutate({
+ projectId,
+ category: 'email',
+ role,
+ credentialId,
+ });
+ }
+ }
+ return { success: true };
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey,
+ });
+ queryClient.invalidateQueries({
+ queryKey: trpc.projects.integrationCredentials.list.queryOptions({
+ projectId,
+ category: 'email',
+ }).queryKey,
+ });
+ },
+ });
+
+ return (
+
+ {/* Step 1: Provider */}
+
toggleStep(1)}
+ >
+
+
+
+ {/* Step 2: Connect */}
+
toggleStep(2)}
+ >
+ {state.provider === 'gmail' ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Step 3: Verify */}
+
toggleStep(3)}
+ >
+ {state.provider === 'gmail' ? (
+ {
+ dispatch({ type: 'SET_VERIFICATION', email: state.gmailEmail });
+ advanceToStep(4);
+ }}
+ />
+ ) : (
+ verifyImapMutation.mutate()}
+ />
+ )}
+
+
+ {/* Step 4: Save */}
+
toggleStep(4)}
+ >
+
+
+
+ );
+}
+
+// ============================================================================
+// Extracted Step Components
+// ============================================================================
+
+function ProviderStep({
+ state,
+ dispatch,
+ advanceToStep,
+}: {
+ state: WizardState;
+ dispatch: React.Dispatch;
+ advanceToStep: (step: number) => void;
+}) {
+ const baseButtonClass =
+ 'flex-1 rounded-md border px-4 py-3 text-sm font-medium transition-colors';
+ const activeClass = 'border-primary bg-primary/5 text-foreground';
+ const inactiveClass =
+ 'border-input text-muted-foreground hover:text-foreground hover:bg-accent/50';
+ const disabledClass = state.isEditing ? 'cursor-not-allowed opacity-60' : '';
+
+ return (
+
+
Email Provider
+
+ {
+ dispatch({ type: 'SET_PROVIDER', provider: 'gmail' });
+ advanceToStep(2);
+ }}
+ className={`${baseButtonClass} ${state.provider === 'gmail' ? activeClass : inactiveClass} ${disabledClass}`}
+ >
+
+ Gmail (OAuth)
+
+ {
+ dispatch({ type: 'SET_PROVIDER', provider: 'imap' });
+ advanceToStep(2);
+ }}
+ className={`${baseButtonClass} ${state.provider === 'imap' ? activeClass : inactiveClass} ${disabledClass}`}
+ >
+ IMAP / SMTP
+
+
+
+ {state.provider === 'gmail'
+ ? 'Connect with Google OAuth. No app password required.'
+ : 'Use IMAP/SMTP credentials (works with any email provider).'}
+
+
+ );
+}
+
+function SaveStep({
+ state,
+ saveMutation,
+ step3Complete,
+}: {
+ state: WizardState;
+ saveMutation: ReturnType>;
+ step3Complete: boolean;
+}) {
+ return (
+
+
+
+ Provider
+
+ {state.provider === 'gmail' ? 'Gmail (OAuth)' : 'IMAP/SMTP'}
+
+
+ {state.verificationEmail && (
+
+ Email
+ {state.verificationEmail}
+
+ )}
+
+
+ saveMutation.mutate()}
+ disabled={!step3Complete || saveMutation.isPending}
+ className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
+ >
+ {saveMutation.isPending
+ ? 'Saving...'
+ : state.isEditing
+ ? 'Update Integration'
+ : 'Save Integration'}
+
+ {saveMutation.isSuccess && (
+ Integration saved successfully.
+ )}
+ {saveMutation.isError && (
+ {saveMutation.error.message}
+ )}
+
+
+ );
+}
+
+// ============================================================================
+// Email Joke Agent Configuration
+// ============================================================================
+
+export function EmailJokeConfig({ projectId }: { projectId: string }) {
+ const [senderEmail, setSenderEmail] = useState('');
+ const [savedEmail, setSavedEmail] = useState(null);
+ const [initialized, setInitialized] = useState(false);
+ const [emailError, setEmailError] = useState(null);
+ const queryClient = useQueryClient();
+
+ // Fetch current triggers config
+ const integrationsQuery = useQuery(trpc.projects.integrations.list.queryOptions({ projectId }));
+
+ // Initialize from existing config (only once)
+ useEffect(() => {
+ if (initialized || !integrationsQuery.data) return;
+ const emailIntegration = (
+ integrationsQuery.data as unknown as Array<{ category: string; triggers: unknown }>
+ ).find((i) => i.category === 'email');
+ if (emailIntegration?.triggers) {
+ const t = emailIntegration.triggers as { senderEmail?: string | null };
+ if (t.senderEmail) {
+ setSenderEmail(t.senderEmail);
+ setSavedEmail(t.senderEmail);
+ }
+ }
+ setInitialized(true);
+ }, [integrationsQuery.data, initialized]);
+
+ // Show error state if query failed
+ if (integrationsQuery.isError) {
+ return (
+
+
+ Failed to load integration config: {integrationsQuery.error.message}
+
+
+ );
+ }
+
+ const updateMutation = useMutation({
+ mutationFn: async (email: string | null) => {
+ await trpcClient.projects.integrations.updateTriggers.mutate({
+ projectId,
+ category: 'email',
+ triggers: { senderEmail: email },
+ });
+ },
+ onSuccess: () => {
+ setSavedEmail(senderEmail || null);
+ queryClient.invalidateQueries({
+ queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey,
+ });
+ },
+ });
+
+ const hasChanges = senderEmail !== (savedEmail ?? '');
+
+ // Validate email format on blur
+ const validateEmail = (email: string) => {
+ if (!email) {
+ setEmailError(null);
+ return true;
+ }
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(email)) {
+ setEmailError('Please enter a valid email address');
+ return false;
+ }
+ setEmailError(null);
+ return true;
+ };
+
+ const handleSave = () => {
+ if (senderEmail && !validateEmail(senderEmail)) {
+ return;
+ }
+ updateMutation.mutate(senderEmail || null);
+ };
+
+ return (
+
+
+
Email Joke Agent
+
+ Configure which emails the joke agent will respond to.
+
+
+
+
Sender Email Filter
+
+ {
+ setSenderEmail(e.target.value);
+ if (emailError) setEmailError(null);
+ }}
+ onBlur={(e) => validateEmail(e.target.value)}
+ className={`flex-1 ${emailError ? 'border-destructive' : ''}`}
+ />
+
+ {updateMutation.isPending ? : 'Save'}
+
+ {savedEmail && (
+ {
+ setSenderEmail('');
+ setEmailError(null);
+ updateMutation.mutate(null);
+ }}
+ disabled={updateMutation.isPending}
+ className="inline-flex h-9 items-center rounded-md border px-3 text-sm hover:bg-accent"
+ >
+ Clear
+
+ )}
+
+ {emailError &&
{emailError}
}
+
+ {savedEmail
+ ? `Currently filtering emails from: ${savedEmail}`
+ : 'No filter set. The agent will process all unread emails.'}
+
+
+ {updateMutation.isSuccess &&
Configuration saved.
}
+ {updateMutation.isError && (
+
{updateMutation.error.message}
+ )}
+
+ );
+}
diff --git a/web/src/components/projects/integration-form.tsx b/web/src/components/projects/integration-form.tsx
index 98e3e067..9ece60c9 100644
--- a/web/src/components/projects/integration-form.tsx
+++ b/web/src/components/projects/integration-form.tsx
@@ -3,9 +3,10 @@ import { trpc, trpcClient } from '@/lib/trpc.js';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CheckCircle, Loader2, XCircle } from 'lucide-react';
import { useEffect, useState } from 'react';
+import { EmailWizard } from './email-wizard.js';
import { PMWizard } from './pm-wizard.js';
-type IntegrationCategory = 'pm' | 'scm';
+type IntegrationCategory = 'pm' | 'scm' | 'email';
interface CredentialOption {
id: number;
@@ -48,7 +49,7 @@ function CredentialSelector({
Select a credential...
{credentials.map((c) => (
- {c.name} ({c.envVarKey}) — {c.value}
+ {c.name} ({c.envVarKey})
))}
@@ -292,6 +293,56 @@ function SCMTab({
);
}
+// ============================================================================
+// Helpers
+// ============================================================================
+
+function buildCredentialMap(
+ data: Array<{ role: string; credentialId: number }> | undefined,
+): Map {
+ const map = new Map();
+ for (const c of data ?? []) {
+ map.set(c.role, c.credentialId);
+ }
+ return map;
+}
+
+function findIntegrationByCategory(
+ integrations: unknown[],
+ category: string,
+): Record | undefined {
+ return integrations.find((i) => (i as Record).category === category) as
+ | Record
+ | undefined;
+}
+
+function TabButton({
+ label,
+ tab,
+ activeTab,
+ onClick,
+}: {
+ label: string;
+ tab: IntegrationCategory;
+ activeTab: IntegrationCategory;
+ onClick: () => void;
+}) {
+ const isActive = activeTab === tab;
+ return (
+
+ {label}
+
+ );
+}
+
// ============================================================================
// Main Integration Form
// ============================================================================
@@ -304,27 +355,9 @@ export function IntegrationForm({ projectId }: { projectId: string }) {
const scmCredsQuery = useQuery(
trpc.projects.integrationCredentials.list.queryOptions({ projectId, category: 'scm' }),
);
-
- const integrations = integrationsQuery.data ?? [];
- const pmIntegration = integrations.find(
- (i) => (i as Record).category === 'pm',
- ) as Record | undefined;
- const scmIntegration = integrations.find(
- (i) => (i as Record).category === 'scm',
- ) as Record | undefined;
-
- const pmProvider = (pmIntegration?.provider as string) ?? 'trello';
- const scmProvider = (scmIntegration?.provider as string) ?? 'github';
-
- const pmCredMap = new Map();
- for (const c of (pmCredsQuery.data ?? []) as Array<{ role: string; credentialId: number }>) {
- pmCredMap.set(c.role, c.credentialId);
- }
-
- const scmCredMap = new Map();
- for (const c of (scmCredsQuery.data ?? []) as Array<{ role: string; credentialId: number }>) {
- scmCredMap.set(c.role, c.credentialId);
- }
+ const emailCredsQuery = useQuery(
+ trpc.projects.integrationCredentials.list.queryOptions({ projectId, category: 'email' }),
+ );
const [activeTab, setActiveTab] = useState('pm');
@@ -332,31 +365,46 @@ export function IntegrationForm({ projectId }: { projectId: string }) {
return Loading integrations...
;
}
+ const integrations = integrationsQuery.data ?? [];
+ const pmIntegration = findIntegrationByCategory(integrations, 'pm');
+ const scmIntegration = findIntegrationByCategory(integrations, 'scm');
+ const emailIntegration = findIntegrationByCategory(integrations, 'email');
+
+ const pmProvider = (pmIntegration?.provider as string) ?? 'trello';
+ const scmProvider = (scmIntegration?.provider as string) ?? 'github';
+ const emailProvider = (emailIntegration?.provider as string) ?? '';
+
+ const pmCredMap = buildCredentialMap(
+ pmCredsQuery.data as Array<{ role: string; credentialId: number }>,
+ );
+ const scmCredMap = buildCredentialMap(
+ scmCredsQuery.data as Array<{ role: string; credentialId: number }>,
+ );
+ const emailCredMap = buildCredentialMap(
+ emailCredsQuery.data as Array<{ role: string; credentialId: number }>,
+ );
+
return (
- setActiveTab('pm')}
- className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
- activeTab === 'pm'
- ? 'bg-background text-foreground shadow-sm'
- : 'text-muted-foreground hover:text-foreground'
- }`}
- >
- Project Management
-
-
+ setActiveTab('scm')}
- className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
- activeTab === 'scm'
- ? 'bg-background text-foreground shadow-sm'
- : 'text-muted-foreground hover:text-foreground'
- }`}
- >
- Source Control
-
+ />
+ setActiveTab('email')}
+ />
{activeTab === 'pm' && (
@@ -375,6 +423,14 @@ export function IntegrationForm({ projectId }: { projectId: string }) {
initialCredentials={scmCredMap}
/>
)}
+
+ {activeTab === 'email' && (
+
+ )}
);
}
diff --git a/web/src/components/projects/project-form-dialog.tsx b/web/src/components/projects/project-form-dialog.tsx
index 372e1ae9..27095a33 100644
--- a/web/src/components/projects/project-form-dialog.tsx
+++ b/web/src/components/projects/project-form-dialog.tsx
@@ -26,7 +26,7 @@ export function ProjectFormDialog({ open, onOpenChange }: ProjectFormDialogProps
const [baseBranch, setBaseBranch] = useState('main');
const createMutation = useMutation({
- mutationFn: (data: { id: string; name: string; repo: string; baseBranch: string }) =>
+ mutationFn: (data: { id: string; name: string; repo?: string; baseBranch: string }) =>
trpcClient.projects.create.mutate(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: trpc.projects.listFull.queryOptions().queryKey });
@@ -53,7 +53,7 @@ export function ProjectFormDialog({ open, onOpenChange }: ProjectFormDialogProps
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
- createMutation.mutate({ id, name, repo, baseBranch });
+ createMutation.mutate({ id, name, repo: repo || undefined, baseBranch });
}
return (
@@ -97,14 +97,14 @@ export function ProjectFormDialog({ open, onOpenChange }: ProjectFormDialogProps
-
Repository
+
Repository (optional)
setRepo(e.target.value)}
placeholder="owner/repo"
- required
/>
+
Leave empty for email-only projects.
Base Branch
diff --git a/web/src/components/projects/project-general-form.tsx b/web/src/components/projects/project-general-form.tsx
index 5b25bba9..61258dea 100644
--- a/web/src/components/projects/project-general-form.tsx
+++ b/web/src/components/projects/project-general-form.tsx
@@ -14,7 +14,7 @@ import { useState } from 'react';
interface Project {
id: string;
name: string;
- repo: string;
+ repo?: string | null;
baseBranch: string | null;
branchPrefix: string | null;
model: string | null;
@@ -26,7 +26,7 @@ interface Project {
export function ProjectGeneralForm({ project }: { project: Project }) {
const queryClient = useQueryClient();
const [name, setName] = useState(project.name);
- const [repo, setRepo] = useState(project.repo);
+ const [repo, setRepo] = useState(project.repo ?? '');
const [baseBranch, setBaseBranch] = useState(project.baseBranch ?? 'main');
const [branchPrefix, setBranchPrefix] = useState(project.branchPrefix ?? 'feature/');
const [model, setModel] = useState(project.model ?? '');
@@ -53,7 +53,7 @@ export function ProjectGeneralForm({ project }: { project: Project }) {
e.preventDefault();
updateMutation.mutate({
name,
- repo,
+ repo: repo || undefined,
baseBranch,
branchPrefix,
model: model || null,
@@ -71,8 +71,13 @@ export function ProjectGeneralForm({ project }: { project: Project }) {
setName(e.target.value)} required />
- Repository
- setRepo(e.target.value)} required />
+ Repository (optional)
+ setRepo(e.target.value)}
+ placeholder="owner/repo"
+ />
diff --git a/web/src/components/projects/projects-table.tsx b/web/src/components/projects/projects-table.tsx
index aacad0ca..db96fc4e 100644
--- a/web/src/components/projects/projects-table.tsx
+++ b/web/src/components/projects/projects-table.tsx
@@ -26,7 +26,7 @@ import { useState } from 'react';
interface Project {
id: string;
name: string;
- repo: string;
+ repo?: string | null;
baseBranch: string | null;
agentBackend: string | null;
cardBudgetUsd: string | null;
@@ -77,7 +77,7 @@ export function ProjectsTable({ projects }: { projects: Project[] }) {
>
{project.name}
- {project.repo}
+ {project.repo || '-'}
{project.baseBranch ?? 'main'}
diff --git a/web/src/routes/oauth/gmail-callback.tsx b/web/src/routes/oauth/gmail-callback.tsx
new file mode 100644
index 00000000..3b02c12a
--- /dev/null
+++ b/web/src/routes/oauth/gmail-callback.tsx
@@ -0,0 +1,165 @@
+import { trpc, trpcClient } from '@/lib/trpc.js';
+import { useQuery } from '@tanstack/react-query';
+import { createRoute, useSearch } from '@tanstack/react-router';
+import { CheckCircle, Loader2, XCircle } from 'lucide-react';
+import { useEffect, useState } from 'react';
+import { rootRoute } from '../__root.js';
+
+interface CallbackSearch {
+ code?: string;
+ state?: string;
+ error?: string;
+}
+
+function GmailCallbackPage() {
+ const search = useSearch({ from: '/oauth/gmail/callback' }) as CallbackSearch;
+ const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
+ const [message, setMessage] = useState('Processing...');
+ const [email, setEmail] = useState(null);
+
+ // Get org credentials for OAuth
+ const credentialsQuery = useQuery(trpc.credentials.list.queryOptions());
+ const credentials = credentialsQuery.data ?? [];
+ const googleClientIdCred = credentials.find(
+ (c: { envVarKey: string }) => c.envVarKey === 'GOOGLE_OAUTH_CLIENT_ID',
+ );
+ const googleClientSecretCred = credentials.find(
+ (c: { envVarKey: string }) => c.envVarKey === 'GOOGLE_OAUTH_CLIENT_SECRET',
+ );
+
+ useEffect(() => {
+ if (search.error) {
+ setStatus('error');
+ setMessage(`Authorization failed: ${search.error}`);
+ // Notify parent window
+ if (window.opener) {
+ window.opener.postMessage(
+ { type: 'gmail-oauth-error', error: search.error },
+ window.location.origin,
+ );
+ }
+ return;
+ }
+
+ if (!search.code || !search.state) {
+ setStatus('error');
+ setMessage('Missing authorization code or state');
+ return;
+ }
+
+ // Wait for credentials to load
+ if (credentialsQuery.isLoading) {
+ return;
+ }
+
+ if (!googleClientIdCred || !googleClientSecretCred) {
+ setStatus('error');
+ setMessage('Google OAuth credentials not configured');
+ return;
+ }
+
+ // Exchange code for tokens - state validation is done server-side
+ const authCode = search.code; // Already validated above
+ const stateParam = search.state; // Pass to server for CSRF validation
+ const exchangeCode = async () => {
+ try {
+ const redirectUri = `${window.location.origin}/oauth/gmail/callback`;
+ const result = await trpcClient.integrationsDiscovery.gmailOAuthCallback.mutate({
+ clientIdCredentialId: (googleClientIdCred as { id: number }).id,
+ clientSecretCredentialId: (googleClientSecretCred as { id: number }).id,
+ code: authCode,
+ redirectUri,
+ state: stateParam,
+ });
+
+ setStatus('success');
+ setEmail(result.email);
+ setMessage(`Connected as ${result.email}`);
+
+ // Notify parent window
+ if (window.opener) {
+ window.opener.postMessage(
+ { type: 'gmail-oauth-complete', email: result.email },
+ window.location.origin,
+ );
+ }
+ } catch (err) {
+ setStatus('error');
+ const errorMessage = err instanceof Error ? err.message : String(err);
+ setMessage(`Failed to complete authorization: ${errorMessage}`);
+
+ // Notify parent window
+ if (window.opener) {
+ window.opener.postMessage(
+ { type: 'gmail-oauth-error', error: errorMessage },
+ window.location.origin,
+ );
+ }
+ }
+ };
+
+ exchangeCode();
+ }, [
+ search.code,
+ search.state,
+ search.error,
+ credentialsQuery.isLoading,
+ googleClientIdCred,
+ googleClientSecretCred,
+ ]);
+
+ return (
+
+
+ {status === 'loading' && (
+ <>
+
+
+
Connecting Gmail
+
{message}
+
+ >
+ )}
+
+ {status === 'success' && (
+ <>
+
+
+
Connected!
+
{message}
+
+
You can close this window now.
+ >
+ )}
+
+ {status === 'error' && (
+ <>
+
+
+
window.close()}
+ className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90"
+ >
+ Close Window
+
+ >
+ )}
+
+
+ );
+}
+
+export const gmailCallbackRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/oauth/gmail/callback',
+ component: GmailCallbackPage,
+ validateSearch: (search: Record): CallbackSearch => ({
+ code: search.code as string | undefined,
+ state: search.state as string | undefined,
+ error: search.error as string | undefined,
+ }),
+});
diff --git a/web/src/routes/route-tree.ts b/web/src/routes/route-tree.ts
index 9b940790..bc0d3e50 100644
--- a/web/src/routes/route-tree.ts
+++ b/web/src/routes/route-tree.ts
@@ -1,6 +1,7 @@
import { rootRoute } from './__root.js';
import { indexRoute } from './index.js';
import { loginRoute } from './login.js';
+import { gmailCallbackRoute } from './oauth/gmail-callback.js';
import { projectDetailRoute } from './projects/$projectId.js';
import { projectsIndexRoute } from './projects/index.js';
import { runDetailRoute } from './runs/$runId.js';
@@ -21,4 +22,5 @@ export const routeTree = rootRoute.addChildren([
settingsAgentsRoute,
settingsPromptsRoute,
webhookLogsRoute,
+ gmailCallbackRoute,
]);