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 ( +
+ + {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 ( + + ); + } + + return ( +
+
+ setName(e.target.value)} + placeholder="Name" + className="flex-1" + /> + setEnvVarKey(e.target.value.toUpperCase())} + placeholder="ENV_VAR_KEY" + className="flex-1" + /> +
+ setValue(e.target.value)} + placeholder="Secret value" + type="password" + /> +
+ + +
+
+ ); +} + +// ============================================================================ +// 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 ( +
+ + + +
+ ); +} + +// ============================================================================ +// 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. +

+ + {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 && ( + + )} + + ); +} + +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. +

+ + + )} + {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 ( +
+ +
+ + +
+

+ {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.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. +

+
+
+ +
+ { + setSenderEmail(e.target.value); + if (emailError) setEmailError(null); + }} + onBlur={(e) => validateEmail(e.target.value)} + className={`flex-1 ${emailError ? 'border-destructive' : ''}`} + /> + + {savedEmail && ( + + )} +
+ {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({ {credentials.map((c) => ( ))} @@ -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 ( + + ); +} + // ============================================================================ // 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('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

- + setRepo(e.target.value)} placeholder="owner/repo" - required /> +

Leave empty for email-only projects.

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 />
- - setRepo(e.target.value)} required /> + + 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' && ( + <> + +
+

Error

+

{message}

+
+ + + )} +
+
+ ); +} + +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, ]);