diff --git a/package-lock.json b/package-lock.json index f35503b0..e3d4dd39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,10 +29,12 @@ "drizzle-orm": "^0.45.1", "eta": "^4.5.0", "hono": "^4.6.14", + "imapflow": "^1.2.10", "jira.js": "^5.3.0", "js-yaml": "^4.1.1", "llmist": "^15.19.0", "marklassian": "^1.1.0", + "nodemailer": "^8.0.1", "pg": "^8.18.0", "trello.js": "^1.2.8", "zangief": "latest", @@ -54,6 +56,7 @@ "@types/dockerode": "^3.3.47", "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.2", + "@types/nodemailer": "^7.0.11", "@types/pg": "^8.16.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -3207,6 +3210,12 @@ "@opentelemetry/api": "^1.1.0" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "license": "MIT", @@ -3949,6 +3958,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pg": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", @@ -4177,6 +4196,17 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@zone-eu/mailsplit": { + "version": "5.4.8", + "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz", + "integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==", + "license": "(MIT OR EUPL-1.1+)", + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.3.7", + "libqp": "2.1.1" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "license": "MIT", @@ -4490,6 +4520,15 @@ "version": "0.4.0", "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/axios": { "version": "1.13.5", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", @@ -6124,6 +6163,15 @@ "version": "2.4.0", "license": "MIT" }, + "node_modules/encoding-japanese": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz", + "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==", + "license": "MIT", + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "license": "MIT", @@ -6848,6 +6896,22 @@ "node": ">= 14" } }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ieee754": { "version": "1.2.1", "funding": [ @@ -6866,6 +6930,23 @@ ], "license": "BSD-3-Clause" }, + "node_modules/imapflow": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.2.10.tgz", + "integrity": "sha512-tqmk0Gj4YBEnGCjjrUYWIf3Z4tzn4iihUcMkBRbafvHF3LqEiYNCSJAAYYbwERFxlikOJ3zzqtEcoxCUTjMv2Q==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.8", + "encoding-japanese": "2.2.0", + "iconv-lite": "0.7.2", + "libbase64": "1.3.0", + "libmime": "5.3.7", + "libqp": "2.1.1", + "nodemailer": "8.0.1", + "pino": "10.3.1", + "socks": "2.8.7" + } + }, "node_modules/impit": { "version": "0.7.5", "license": "Apache-2.0", @@ -7089,6 +7170,15 @@ "url": "https://opencollective.com/ioredis" } }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "dev": true, @@ -7659,6 +7749,42 @@ "win32" ] }, + "node_modules/libbase64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", + "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==", + "license": "MIT" + }, + "node_modules/libmime": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz", + "integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==", + "license": "MIT", + "dependencies": { + "encoding-japanese": "2.2.0", + "iconv-lite": "0.6.3", + "libbase64": "1.3.0", + "libqp": "2.1.1" + } + }, + "node_modules/libmime/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/libqp": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", + "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", + "license": "MIT" + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -8156,6 +8282,15 @@ "version": "2.0.27", "license": "MIT" }, + "node_modules/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "license": "MIT", @@ -8170,6 +8305,15 @@ "node": ">=0.10.0" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/once": { "version": "1.4.0", "license": "ISC", @@ -8448,6 +8592,43 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/playwright-core": { "version": "1.57.0", "license": "Apache-2.0", @@ -8573,6 +8754,22 @@ "version": "2.0.1", "license": "MIT" }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/progress": { "version": "2.0.3", "license": "MIT", @@ -8624,6 +8821,12 @@ "node": ">=6" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -8683,6 +8886,15 @@ "node": ">=10" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/redis-errors": { "version": "1.2.0", "license": "MIT", @@ -8864,6 +9076,15 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "license": "MIT" @@ -8944,6 +9165,39 @@ "node": ">=8" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -9251,6 +9505,18 @@ "node": ">=0.8" } }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/through": { "version": "2.3.8", "dev": true, diff --git a/package.json b/package.json index 64c5401c..5b28ca7e 100644 --- a/package.json +++ b/package.json @@ -69,10 +69,12 @@ "drizzle-orm": "^0.45.1", "eta": "^4.5.0", "hono": "^4.6.14", + "imapflow": "^1.2.10", "jira.js": "^5.3.0", "js-yaml": "^4.1.1", "llmist": "^15.19.0", "marklassian": "^1.1.0", + "nodemailer": "^8.0.1", "pg": "^8.18.0", "trello.js": "^1.2.8", "zangief": "latest", @@ -90,6 +92,7 @@ "@types/dockerode": "^3.3.47", "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.2", + "@types/nodemailer": "^7.0.11", "@types/pg": "^8.16.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/src/agents/definitions/schema.ts b/src/agents/definitions/schema.ts index d9e77a0d..337b239e 100644 --- a/src/agents/definitions/schema.ts +++ b/src/agents/definitions/schema.ts @@ -16,11 +16,14 @@ const CapabilitiesSchema = z.object({ canCreatePR: z.boolean(), canUpdateChecklists: z.boolean(), isReadOnly: z.boolean(), + canAccessEmail: z.boolean().optional(), }); const ToolsSchema = z.object({ /** Named tool set references resolved via TOOL_SET_REGISTRY */ - sets: z.array(z.enum(['pm', 'pm_checklist', 'session', 'github_review', 'github_ci', 'all'])), + sets: z.array( + z.enum(['pm', 'pm_checklist', 'session', 'github_review', 'github_ci', 'email', 'all']), + ), /** SDK tools preset: "all" or "readOnly" */ sdkTools: z.enum(['all', 'readOnly']), }); diff --git a/src/agents/definitions/strategies.ts b/src/agents/definitions/strategies.ts index f6e75646..2a92640e 100644 --- a/src/agents/definitions/strategies.ts +++ b/src/agents/definitions/strategies.ts @@ -55,6 +55,9 @@ export const GITHUB_CI_TOOLS = [ 'UpdatePRComment', ]; +/** Email tools for agents that need email access */ +export const EMAIL_TOOLS = ['SendEmail', 'SearchEmails', 'ReadEmail', 'ReplyToEmail']; + export const SESSION_TOOL = 'Finish'; export const ALL_SDK_TOOLS = ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep']; @@ -69,6 +72,7 @@ export const TOOL_SET_REGISTRY: Record = { session: [SESSION_TOOL], github_review: GITHUB_REVIEW_TOOLS, github_ci: GITHUB_CI_TOOLS, + email: EMAIL_TOOLS, // 'all' is a sentinel — handled by returning allTools unfiltered }; diff --git a/src/agents/shared/capabilities.ts b/src/agents/shared/capabilities.ts index fccf9e64..1bab3763 100644 --- a/src/agents/shared/capabilities.ts +++ b/src/agents/shared/capabilities.ts @@ -21,6 +21,8 @@ export interface AgentCapabilities { canUpdateChecklists: boolean; /** True for agents that only interact with the PM system (no repo changes) */ isReadOnly: boolean; + /** Can the agent send/search/read emails? (default: false) */ + canAccessEmail?: boolean; } /** diff --git a/src/agents/shared/gadgets.ts b/src/agents/shared/gadgets.ts index c58160d8..581652b1 100644 --- a/src/agents/shared/gadgets.ts +++ b/src/agents/shared/gadgets.ts @@ -8,6 +8,7 @@ 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 { CreatePR, CreatePRReview, @@ -75,6 +76,10 @@ export function buildWorkItemGadgets(caps: AgentCapabilities): CreateBuilderOpti // UpdateChecklistItem gated by capability — prevents planning from marking items complete // prematurely, while respond-to-planning-comment CAN update them ...(caps.canUpdateChecklists ? [new PMUpdateChecklistItem(), new PMDeleteChecklistItem()] : []), + // Email gadgets (gated by capability — disabled by default) + ...(caps.canAccessEmail + ? [new SendEmail(), new SearchEmails(), new ReadEmail(), new ReplyToEmail()] + : []), // Session control new Finish(), ]; diff --git a/src/config/integrationRoles.ts b/src/config/integrationRoles.ts index 74207f4f..11157597 100644 --- a/src/config/integrationRoles.ts +++ b/src/config/integrationRoles.ts @@ -1,10 +1,11 @@ -export type IntegrationCategory = 'pm' | 'scm'; -export type IntegrationProvider = 'trello' | 'jira' | 'github'; +export type IntegrationCategory = 'pm' | 'scm' | 'email'; +export type IntegrationProvider = 'trello' | 'jira' | 'github' | 'imap'; export const PROVIDER_CATEGORY: Record = { trello: 'pm', jira: 'pm', github: 'scm', + imap: 'email', }; export interface CredentialRoleDef { @@ -30,4 +31,12 @@ export const PROVIDER_CREDENTIAL_ROLES: Record(); + +/** + * Run a function with email credentials in scope. + */ +export function withEmailCredentials(creds: EmailCredentials, fn: () => Promise): Promise { + return emailCredentialStore.run(creds, fn); +} + +/** + * Get the current email credentials from AsyncLocalStorage. + * Throws if no credentials are in scope. + */ +export function getEmailCredentials(): EmailCredentials { + const scoped = emailCredentialStore.getStore(); + if (!scoped) { + throw new Error( + 'No email credentials in scope. Wrap the call with withEmailCredentials() or ensure per-project email credentials are set in the database.', + ); + } + return scoped; +} + +/** + * Create an ImapFlow client configured with scoped credentials. + */ +function createImapClient(): ImapFlow { + const creds = getEmailCredentials(); + return new ImapFlow({ + host: creds.imapHost, + port: creds.imapPort, + secure: true, // Use TLS + auth: { + user: creds.username, + pass: creds.password, + }, + logger: false, // Suppress imapflow's built-in logging + connectionTimeout: 30000, // 30s to establish connection + greetingTimeout: 15000, // 15s to receive server greeting + socketTimeout: 60000, // 60s for socket operations + }); +} + +/** + * Create a nodemailer transporter configured with scoped credentials. + */ +function createSmtpTransport(): Transporter { + const creds = getEmailCredentials(); + 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, + }, + }); +} + +/** + * Parse an email address object/string into a simple string. + */ +function parseAddress(addr: unknown): string { + if (!addr) return ''; + if (typeof addr === 'string') return addr; + if (typeof addr === 'object' && addr !== null) { + const obj = addr as { address?: string; name?: string }; + if (obj.address) { + return obj.name ? `${obj.name} <${obj.address}>` : obj.address; + } + } + return String(addr); +} + +/** + * Parse an array of addresses. + */ +function parseAddresses(addrs: unknown): string[] { + if (!addrs) return []; + if (Array.isArray(addrs)) return addrs.map(parseAddress).filter(Boolean); + return [parseAddress(addrs)].filter(Boolean); +} + +/** + * Build an IMAP search query from EmailSearchCriteria. + */ +function buildSearchQuery(criteria: EmailSearchCriteria): Record { + const searchQuery: Record = {}; + + if (criteria.from) searchQuery.from = criteria.from; + if (criteria.to) searchQuery.to = criteria.to; + if (criteria.subject) searchQuery.subject = criteria.subject; + if (criteria.body) searchQuery.body = criteria.body; + if (criteria.since) searchQuery.since = new Date(criteria.since); + if (criteria.before) searchQuery.before = new Date(criteria.before); + if (criteria.unseen) searchQuery.seen = false; + + return Object.keys(searchQuery).length > 0 ? searchQuery : { all: true }; +} + +/** + * Parse email body content from raw source. + * Simple regex-based parsing (for proper MIME parsing, use mailparser). + */ +function parseEmailBody(source: string): { textBody: string; htmlBody?: string } { + let textBody = ''; + let htmlBody: string | undefined; + + const textMatch = source.match( + /Content-Type: text\/plain[\s\S]*?\r\n\r\n([\s\S]*?)(?=--|\r\n\r\n--|\Z)/i, + ); + if (textMatch) { + textBody = textMatch[1].trim(); + } else { + const headerEnd = source.indexOf('\r\n\r\n'); + if (headerEnd > 0) { + textBody = source.slice(headerEnd + 4).trim(); + } + } + + const htmlMatch = source.match( + /Content-Type: text\/html[\s\S]*?\r\n\r\n([\s\S]*?)(?=--|\r\n\r\n--|\Z)/i, + ); + if (htmlMatch) { + htmlBody = htmlMatch[1].trim(); + } + + return { textBody, htmlBody }; +} + +/** + * Check if a node represents an attachment and extract its info. + */ +function tryExtractAttachment( + node: Record, +): { filename: string; contentType: string; size: number } | null { + if (node.disposition !== 'attachment' || !node.dispositionParameters) { + return null; + } + const params = node.dispositionParameters as { filename?: string }; + return { + filename: params.filename ?? 'attachment', + contentType: `${node.type ?? 'application'}/${node.subtype ?? 'octet-stream'}`, + size: (node.size as number) ?? 0, + }; +} + +/** + * Extract attachment info from IMAP body structure using iterative traversal. + */ +function extractAttachments( + bodyStruct: unknown, +): Array<{ filename: string; contentType: string; size: number }> { + const attachments: Array<{ filename: string; contentType: string; size: number }> = []; + + if (!bodyStruct || typeof bodyStruct !== 'object') { + return attachments; + } + + // Use a stack for iterative traversal to avoid recursion complexity + const stack: unknown[] = [bodyStruct]; + + while (stack.length > 0) { + const current = stack.pop(); + if (typeof current !== 'object' || current === null) continue; + + const node = current as Record; + const attachment = tryExtractAttachment(node); + if (attachment) { + attachments.push(attachment); + } + + if (Array.isArray(node.childNodes)) { + stack.push(...node.childNodes); + } + } + + return attachments; +} + +/** + * Parse threading headers from raw email source. + */ +function parseThreadingHeaders(source: string): { + messageId: string; + inReplyTo?: string; + references: string[]; +} { + const messageIdMatch = source.match(/Message-ID:\s*<([^>]+)>/i); + const inReplyToMatch = source.match(/In-Reply-To:\s*<([^>]+)>/i); + const referencesMatch = source.match(/References:\s*(.+?)(?=\r\n[^\s]|\r\n\r\n)/is); + + const references: string[] = []; + if (referencesMatch) { + const refMatches = referencesMatch[1].match(/<[^>]+>/g); + if (refMatches) { + references.push(...refMatches.map((r) => r.slice(1, -1))); + } + } + + return { + messageId: messageIdMatch?.[1] ?? '', + inReplyTo: inReplyToMatch?.[1], + references, + }; +} + +// ============================================================================ +// IMAP Operations +// ============================================================================ + +/** + * Search emails in a mailbox folder using IMAP criteria. + */ +export async function searchEmails( + folder: string, + criteria: EmailSearchCriteria, + maxResults: number, +): Promise { + const client = createImapClient(); + + try { + await client.connect(); + logger.debug('Connected to IMAP server for search', { folder }); + + const lock = await client.getMailboxLock(folder); + try { + const query = buildSearchQuery(criteria); + const searchResult = await client.search(query, { uid: true }); + + if (searchResult === false || searchResult.length === 0) { + return []; + } + + logger.debug('IMAP search returned UIDs', { count: searchResult.length, folder }); + + // Limit results and sort by UID descending (newest first) + const limitedUids = searchResult.slice(-maxResults).reverse(); + + // Fetch message summaries + const results: EmailSummary[] = []; + for await (const msg of client.fetch(limitedUids, { + uid: true, + envelope: true, + bodyStructure: true, + source: { start: 0, maxLength: 500 }, + })) { + const envelope = msg.envelope; + results.push({ + uid: msg.uid, + date: envelope?.date ?? new Date(), + from: parseAddress(envelope?.from?.[0]), + to: parseAddresses(envelope?.to), + subject: envelope?.subject ?? '(no subject)', + snippet: msg.source?.toString('utf8').slice(0, 200) ?? '', + }); + } + + return results; + } finally { + lock.release(); + } + } finally { + await client.logout(); + } +} + +/** + * Read a full email message by UID. + */ +export async function readEmail(folder: string, uid: number): Promise { + const client = createImapClient(); + + try { + await client.connect(); + logger.debug('Connected to IMAP server for read', { folder, uid }); + + const lock = await client.getMailboxLock(folder); + try { + const message = await client.fetchOne( + uid, + { uid: true, envelope: true, bodyStructure: true, source: true }, + { uid: true }, + ); + + if (!message) { + throw new Error(`Email with UID ${uid} not found in folder ${folder}`); + } + + const envelope = message.envelope; + const source = message.source?.toString('utf8') ?? ''; + + const { textBody, htmlBody } = parseEmailBody(source); + const attachments = extractAttachments(message.bodyStructure); + const { messageId, inReplyTo, references } = parseThreadingHeaders(source); + + return { + uid, + messageId, + date: envelope?.date ?? new Date(), + from: parseAddress(envelope?.from?.[0]), + to: parseAddresses(envelope?.to), + cc: parseAddresses(envelope?.cc), + subject: envelope?.subject ?? '(no subject)', + textBody, + htmlBody, + attachments, + inReplyTo, + references, + }; + } finally { + lock.release(); + } + } finally { + await client.logout(); + } +} + +// ============================================================================ +// SMTP Operations +// ============================================================================ + +/** + * Send an email via SMTP. + */ +export async function sendEmail(options: SendEmailOptions): Promise { + const creds = getEmailCredentials(); + const transport = createSmtpTransport(); + + try { + logger.debug('Sending email via SMTP', { + to: options.to, + subject: options.subject, + }); + + const result = await transport.sendMail({ + from: creds.username, + to: options.to, + cc: options.cc, + bcc: options.bcc, + subject: options.subject, + text: options.body, + html: options.html, + }); + + logger.debug('Email sent successfully', { + messageId: result.messageId, + accepted: result.accepted, + }); + + return { + messageId: result.messageId, + accepted: result.accepted as string[], + rejected: result.rejected as string[], + }; + } finally { + await transport.close(); + } +} + +/** + * Reply to an existing email thread. + */ +export async function replyToEmail(options: ReplyEmailOptions): Promise { + // First, fetch the original message to get threading info + const original = await readEmail(options.folder, options.uid); + + // 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))); + } else { + // Reply only to sender + recipients.push(original.from); + } + + // Build subject with Re: prefix if not already present + const subject = original.subject.startsWith('Re:') ? original.subject : `Re: ${original.subject}`; + + // Build references header for threading + const references = [...original.references]; + if (original.messageId && !references.includes(original.messageId)) { + references.push(original.messageId); + } + + // Send the reply + const creds = getEmailCredentials(); + const transport = createSmtpTransport(); + + try { + logger.debug('Sending reply via SMTP', { + to: recipients, + subject, + inReplyTo: original.messageId, + }); + + const result = await transport.sendMail({ + from: creds.username, + to: recipients, + subject, + text: options.body, + inReplyTo: original.messageId ? `<${original.messageId}>` : undefined, + references: references.map((r) => `<${r}>`).join(' ') || undefined, + }); + + logger.debug('Reply sent successfully', { + messageId: result.messageId, + accepted: result.accepted, + }); + + return { + messageId: result.messageId, + accepted: result.accepted as string[], + rejected: result.rejected as string[], + }; + } finally { + await transport.close(); + } +} diff --git a/src/email/index.ts b/src/email/index.ts new file mode 100644 index 00000000..cb399731 --- /dev/null +++ b/src/email/index.ts @@ -0,0 +1,28 @@ +// Email client and credential scoping +export { + withEmailCredentials, + getEmailCredentials, + searchEmails, + readEmail, + sendEmail, + replyToEmail, +} from './client.js'; + +// Integration credential resolution +export { + resolveEmailCredentials, + withEmailIntegration, + hasEmailIntegration, +} from './integration.js'; + +// Types +export type { + EmailCredentials, + EmailSearchCriteria, + EmailSummary, + EmailMessage, + EmailAttachment, + SendEmailOptions, + SendEmailResult, + ReplyEmailOptions, +} from './types.js'; diff --git a/src/email/integration.ts b/src/email/integration.ts new file mode 100644 index 00000000..8f19d66a --- /dev/null +++ b/src/email/integration.ts @@ -0,0 +1,77 @@ +/** + * Email integration — credential resolution and scoping. + * + * Provides withEmailIntegration() for establishing email credential scope + * similar to withPMCredentials() for PM integrations. + */ + +import { getIntegrationCredential } from '../config/provider.js'; +import { logger } from '../utils/logging.js'; +import { withEmailCredentials } from './client.js'; +import type { EmailCredentials } from './types.js'; + +/** + * Resolve email credentials for a project from the database. + */ +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) { + return null; + } + + const imapPort = Number.parseInt(imapPortStr, 10); + const smtpPort = Number.parseInt(smtpPortStr, 10); + + if (Number.isNaN(imapPort) || Number.isNaN(smtpPort)) { + return null; + } + + return { + imapHost, + imapPort, + smtpHost, + smtpPort, + username, + password, + }; + } catch (error) { + logger.warn('Failed to resolve email credentials', { + projectId, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} + +/** + * Run a function with email credentials in scope for a project. + * + * If no email integration is configured for the project, runs fn() without + * email credentials (gadgets will fail with clear error messages). + */ +export async function withEmailIntegration(projectId: string, fn: () => Promise): Promise { + const creds = await resolveEmailCredentials(projectId); + if (!creds) { + // No email integration configured — run without credentials + return fn(); + } + return withEmailCredentials(creds, fn); +} + +/** + * Check if email integration is configured for a project. + */ +export async function hasEmailIntegration(projectId: string): Promise { + const creds = await resolveEmailCredentials(projectId); + return creds !== null; +} diff --git a/src/email/types.ts b/src/email/types.ts new file mode 100644 index 00000000..707b8a5e --- /dev/null +++ b/src/email/types.ts @@ -0,0 +1,94 @@ +/** + * Email credentials for IMAP/SMTP connections. + */ +export interface EmailCredentials { + imapHost: string; + imapPort: number; + smtpHost: string; + smtpPort: number; + username: string; + password: string; +} + +/** + * Search criteria for IMAP email search. + */ +export interface EmailSearchCriteria { + from?: string; + to?: string; + subject?: string; + body?: string; + since?: string; // YYYY-MM-DD + before?: string; // YYYY-MM-DD + unseen?: boolean; +} + +/** + * Email message summary (from search results). + */ +export interface EmailSummary { + uid: number; + date: Date; + from: string; + to: string[]; + subject: string; + snippet: string; +} + +/** + * Full email message with body. + */ +export interface EmailMessage { + uid: number; + messageId: string; + date: Date; + from: string; + to: string[]; + cc: string[]; + subject: string; + textBody: string; + htmlBody?: string; + attachments: EmailAttachment[]; + inReplyTo?: string; + references: string[]; +} + +/** + * Email attachment metadata. + */ +export interface EmailAttachment { + filename: string; + contentType: string; + size: number; +} + +/** + * Options for sending an email. + */ +export interface SendEmailOptions { + to: string[]; + subject: string; + body: string; + html?: string; + cc?: string[]; + bcc?: string[]; +} + +/** + * Result of sending an email. + */ +export interface SendEmailResult { + messageId: string; + accepted: string[]; + rejected: string[]; +} + +/** + * Options for replying to an email. + */ +export interface ReplyEmailOptions { + folder: string; + uid: number; + body: string; + replyAll: boolean; +} diff --git a/src/gadgets/email/ReadEmail.ts b/src/gadgets/email/ReadEmail.ts new file mode 100644 index 00000000..3d5110f8 --- /dev/null +++ b/src/gadgets/email/ReadEmail.ts @@ -0,0 +1,27 @@ +import { Gadget, z } from 'llmist'; +import { readEmail } from './core/readEmail.js'; + +export class ReadEmail extends Gadget({ + name: 'ReadEmail', + description: + 'Read the full content of an email by its UID. Use the UID from SearchEmails results.', + timeoutMs: 30000, + schema: z.object({ + folder: z.string().min(1).max(100).default('INBOX').describe('Mailbox folder (default: INBOX)'), + uid: z.number().int().positive().describe('Email UID (from SearchEmails results)'), + }), + examples: [ + { + params: { folder: 'INBOX', uid: 123 }, + comment: 'Read email with UID 123 from the inbox', + }, + { + params: { folder: 'Sent', uid: 456 }, + comment: 'Read a sent email with UID 456', + }, + ], +}) { + override async execute(params: this['params']): Promise { + return readEmail(params.folder, params.uid); + } +} diff --git a/src/gadgets/email/ReplyToEmail.ts b/src/gadgets/email/ReplyToEmail.ts new file mode 100644 index 00000000..e33841cf --- /dev/null +++ b/src/gadgets/email/ReplyToEmail.ts @@ -0,0 +1,47 @@ +import { Gadget, z } from 'llmist'; +import { replyToEmail } from './core/replyToEmail.js'; + +export class ReplyToEmail extends Gadget({ + name: 'ReplyToEmail', + description: + 'Reply to an existing email thread. The reply will be properly threaded with the original message.', + timeoutMs: 30000, + schema: z.object({ + folder: z + .string() + .min(1) + .max(100) + .default('INBOX') + .describe('Mailbox folder containing the original email (default: INBOX)'), + uid: z.number().int().positive().describe('Email UID to reply to (from SearchEmails results)'), + body: z.string().min(1).max(10_000_000).describe('Reply body text'), + replyAll: z + .boolean() + .default(false) + .describe('Reply to all recipients (default: false, reply only to sender)'), + }), + examples: [ + { + params: { + folder: 'INBOX', + uid: 123, + body: 'Thank you for the update. We will proceed with the plan.', + replyAll: false, + }, + comment: 'Reply to the sender only', + }, + { + params: { + folder: 'INBOX', + uid: 456, + body: 'I have reviewed the proposal and have some feedback...', + replyAll: true, + }, + comment: 'Reply to all recipients in the thread', + }, + ], +}) { + override async execute(params: this['params']): Promise { + return replyToEmail(params.folder, params.uid, params.body, params.replyAll); + } +} diff --git a/src/gadgets/email/SearchEmails.ts b/src/gadgets/email/SearchEmails.ts new file mode 100644 index 00000000..bb0f4a7b --- /dev/null +++ b/src/gadgets/email/SearchEmails.ts @@ -0,0 +1,65 @@ +import { Gadget, z } from 'llmist'; +import { searchEmails } from './core/searchEmails.js'; + +export class SearchEmails extends Gadget({ + name: 'SearchEmails', + description: + 'Search emails in a mailbox folder using IMAP criteria. Returns a list of matching emails with UID, date, subject, and sender.', + timeoutMs: 60000, + schema: z.object({ + folder: z + .string() + .min(1) + .max(100) + .default('INBOX') + .describe('Mailbox folder to search (default: INBOX)'), + criteria: z + .object({ + from: z.string().optional().describe('Sender email or name'), + to: z.string().optional().describe('Recipient email or name'), + subject: z.string().optional().describe('Subject line contains this text'), + body: z.string().optional().describe('Body contains this text'), + since: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional() + .describe('Messages since date (YYYY-MM-DD)'), + before: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional() + .describe('Messages before date (YYYY-MM-DD)'), + unseen: z.boolean().optional().describe('Only unread messages'), + }) + .strict() + .describe('IMAP search criteria'), + maxResults: z + .number() + .min(1) + .max(50) + .default(10) + .describe('Maximum number of results to return (1-50, default: 10)'), + }), + examples: [ + { + params: { + folder: 'INBOX', + criteria: { from: 'client@example.com', unseen: true }, + maxResults: 5, + }, + comment: 'Search for unread emails from a specific sender', + }, + { + params: { + folder: 'INBOX', + criteria: { subject: 'urgent', since: '2024-01-01' }, + maxResults: 10, + }, + comment: 'Search for emails with "urgent" in the subject since January 2024', + }, + ], +}) { + override async execute(params: this['params']): Promise { + return searchEmails(params.folder, params.criteria, params.maxResults); + } +} diff --git a/src/gadgets/email/SendEmail.ts b/src/gadgets/email/SendEmail.ts new file mode 100644 index 00000000..e33e844f --- /dev/null +++ b/src/gadgets/email/SendEmail.ts @@ -0,0 +1,50 @@ +import { Gadget, z } from 'llmist'; +import { sendEmail } from './core/sendEmail.js'; + +export class SendEmail extends Gadget({ + name: 'SendEmail', + description: 'Send an email via SMTP. Use this to communicate with external parties via email.', + timeoutMs: 30000, + schema: z.object({ + to: z.array(z.string().email()).min(1).describe('Recipient email addresses'), + subject: z.string().min(1).max(998).describe('Email subject line (max 998 chars per RFC 2822)'), + body: z.string().min(1).max(10_000_000).describe('Email body (plain text)'), + html: z + .string() + .max(10_000_000) + .optional() + .describe('HTML body (optional, overrides plain text for HTML clients)'), + cc: z.array(z.string().email()).optional().describe('CC recipients'), + bcc: z.array(z.string().email()).optional().describe('BCC recipients'), + }), + examples: [ + { + params: { + to: ['user@example.com'], + subject: 'Status Update', + body: 'The task has been completed successfully.', + }, + comment: 'Send a simple status update email', + }, + { + params: { + to: ['team@example.com'], + subject: 'Weekly Report', + body: 'Please find the weekly report attached.', + cc: ['manager@example.com'], + }, + comment: 'Send an email with CC recipients', + }, + ], +}) { + override async execute(params: this['params']): Promise { + return sendEmail({ + to: params.to, + subject: params.subject, + body: params.body, + html: params.html, + cc: params.cc, + bcc: params.bcc, + }); + } +} diff --git a/src/gadgets/email/core/readEmail.ts b/src/gadgets/email/core/readEmail.ts new file mode 100644 index 00000000..48bc709d --- /dev/null +++ b/src/gadgets/email/core/readEmail.ts @@ -0,0 +1,43 @@ +import { readEmail as readEmailClient } from '../../../email/client.js'; +import { logger } from '../../../utils/logging.js'; + +export async function readEmail(folder: string, uid: number): Promise { + try { + const email = await readEmailClient(folder, uid); + + const lines: string[] = [`From: ${email.from}`, `To: ${email.to.join(', ')}`]; + + if (email.cc.length > 0) { + lines.push(`CC: ${email.cc.join(', ')}`); + } + + lines.push(`Date: ${email.date.toISOString()}`, `Subject: ${email.subject}`); + + if (email.messageId) { + lines.push(`Message-ID: ${email.messageId}`); + } + + if (email.attachments.length > 0) { + lines.push( + `Attachments: ${email.attachments.map((a) => `${a.filename} (${a.contentType}, ${a.size} bytes)`).join(', ')}`, + ); + } + + // Show text body, or HTML body if text is not available + if (email.textBody) { + lines.push('', '--- Body (Text) ---', '', email.textBody); + } else if (email.htmlBody) { + lines.push('', '--- Body (HTML) ---', '', email.htmlBody); + } + + return lines.join('\n'); + } catch (error) { + logger.error('Email read failed', { + folder, + uid, + error: error instanceof Error ? error.message : String(error), + }); + const message = error instanceof Error ? error.message : String(error); + return `Error reading email: ${message}`; + } +} diff --git a/src/gadgets/email/core/replyToEmail.ts b/src/gadgets/email/core/replyToEmail.ts new file mode 100644 index 00000000..e6e7d938 --- /dev/null +++ b/src/gadgets/email/core/replyToEmail.ts @@ -0,0 +1,27 @@ +import { replyToEmail as replyToEmailClient } from '../../../email/client.js'; +import { logger } from '../../../utils/logging.js'; + +export async function replyToEmail( + folder: string, + uid: number, + body: string, + replyAll: boolean, +): Promise { + try { + const result = await replyToEmailClient({ folder, uid, body, replyAll }); + + const accepted = result.accepted.join(', '); + const rejected = result.rejected.length > 0 ? ` (rejected: ${result.rejected.join(', ')})` : ''; + + return `Reply sent to ${accepted}${rejected} (Message-ID: ${result.messageId})`; + } catch (error) { + logger.error('Email reply failed', { + folder, + uid, + replyAll, + error: error instanceof Error ? error.message : String(error), + }); + const message = error instanceof Error ? error.message : String(error); + return `Error sending reply: ${message}`; + } +} diff --git a/src/gadgets/email/core/searchEmails.ts b/src/gadgets/email/core/searchEmails.ts new file mode 100644 index 00000000..78e3012e --- /dev/null +++ b/src/gadgets/email/core/searchEmails.ts @@ -0,0 +1,36 @@ +import { searchEmails as searchEmailsClient } from '../../../email/client.js'; +import type { EmailSearchCriteria } from '../../../email/types.js'; +import { logger } from '../../../utils/logging.js'; + +export async function searchEmails( + folder: string, + criteria: EmailSearchCriteria, + maxResults: number, +): Promise { + try { + const results = await searchEmailsClient(folder, criteria, maxResults); + + if (results.length === 0) { + return 'No emails found matching the search criteria.'; + } + + const lines: string[] = [`Found ${results.length} email(s):\n`]; + + results.forEach((email, index) => { + const dateStr = email.date.toISOString().split('T')[0]; + lines.push( + `${index + 1}. [UID:${email.uid}] ${dateStr} - "${email.subject}" from ${email.from}`, + ); + }); + + return lines.join('\n'); + } catch (error) { + logger.error('Email search failed', { + folder, + criteria, + error: error instanceof Error ? error.message : String(error), + }); + const message = error instanceof Error ? error.message : String(error); + return `Error searching emails: ${message}`; + } +} diff --git a/src/gadgets/email/core/sendEmail.ts b/src/gadgets/email/core/sendEmail.ts new file mode 100644 index 00000000..a3ac851f --- /dev/null +++ b/src/gadgets/email/core/sendEmail.ts @@ -0,0 +1,22 @@ +import { sendEmail as sendEmailClient } from '../../../email/client.js'; +import type { SendEmailOptions } from '../../../email/types.js'; +import { logger } from '../../../utils/logging.js'; + +export async function sendEmail(options: SendEmailOptions): Promise { + try { + const result = await sendEmailClient(options); + + const accepted = result.accepted.join(', '); + const rejected = result.rejected.length > 0 ? ` (rejected: ${result.rejected.join(', ')})` : ''; + + return `Email sent successfully to ${accepted}${rejected} (Message-ID: ${result.messageId})`; + } catch (error) { + logger.error('Email send failed', { + to: options.to, + subject: options.subject, + error: error instanceof Error ? error.message : String(error), + }); + const message = error instanceof Error ? error.message : String(error); + return `Error sending email: ${message}`; + } +} diff --git a/src/gadgets/email/index.ts b/src/gadgets/email/index.ts new file mode 100644 index 00000000..322c871c --- /dev/null +++ b/src/gadgets/email/index.ts @@ -0,0 +1,4 @@ +export { SendEmail } from './SendEmail.js'; +export { SearchEmails } from './SearchEmails.js'; +export { ReadEmail } from './ReadEmail.js'; +export { ReplyToEmail } from './ReplyToEmail.js'; diff --git a/src/gadgets/index.ts b/src/gadgets/index.ts index bc147e29..e24d3d4f 100644 --- a/src/gadgets/index.ts +++ b/src/gadgets/index.ts @@ -9,5 +9,9 @@ export { VerifyChanges } from './VerifyChanges.js'; // Search gadgets export { RipGrep } from './RipGrep.js'; export { AstGrep } from './AstGrep.js'; + // GitHub gadgets export { GetPRDetails, GetPRComments, ReplyToReviewComment } from './github/index.js'; + +// Email gadgets +export { SendEmail, SearchEmails, ReadEmail, ReplyToEmail } from './email/index.js'; diff --git a/src/pm/webhook-handler.ts b/src/pm/webhook-handler.ts index 6426c03d..f737dc40 100644 --- a/src/pm/webhook-handler.ts +++ b/src/pm/webhook-handler.ts @@ -7,6 +7,7 @@ * ack comment management) is delegated to the PMIntegration interface. */ +import { withEmailIntegration } from '../email/integration.js'; import { withGitHubToken } from '../github/client.js'; import { getPersonaToken } from '../github/personas.js'; import type { TriggerRegistry } from '../triggers/registry.js'; @@ -52,10 +53,12 @@ async function executeAgent( try { await integration.withCredentials(project.id, () => - withGitHubToken(githubToken, () => - runAgentExecutionPipeline(result, project, config, { - logLabel: `${integration.type} agent`, - }), + withEmailIntegration(project.id, () => + withGitHubToken(githubToken, () => + runAgentExecutionPipeline(result, project, config, { + logLabel: `${integration.type} agent`, + }), + ), ), ); } finally { diff --git a/src/triggers/github/webhook-handler.ts b/src/triggers/github/webhook-handler.ts index e818fa6c..f1de81ae 100644 --- a/src/triggers/github/webhook-handler.ts +++ b/src/triggers/github/webhook-handler.ts @@ -1,5 +1,6 @@ import { INITIAL_MESSAGES } from '../../config/agentMessages.js'; import { loadProjectConfigByRepo } from '../../config/provider.js'; +import { withEmailIntegration } from '../../email/integration.js'; import { getSessionState } from '../../gadgets/sessionState.js'; import { githubClient, withGitHubToken } from '../../github/client.js'; import { getPersonaToken, resolvePersonaIdentities } from '../../github/personas.js'; @@ -116,8 +117,10 @@ async function executeGitHubAgent( (t) => pmRegistry.getOrNull(t), () => withPMProvider(pmProvider, () => - withGitHubToken(githubToken, () => - runAgentExecutionPipeline(result, project, config, executionConfig), + withEmailIntegration(project.id, () => + withGitHubToken(githubToken, () => + runAgentExecutionPipeline(result, project, config, executionConfig), + ), ), ), ); diff --git a/src/triggers/shared/manual-runner.ts b/src/triggers/shared/manual-runner.ts index 0a2efb20..2946aa15 100644 --- a/src/triggers/shared/manual-runner.ts +++ b/src/triggers/shared/manual-runner.ts @@ -1,5 +1,6 @@ import { runAgent } from '../../agents/registry.js'; import { getRunById } from '../../db/repositories/runsRepository.js'; +import { withEmailIntegration } from '../../email/integration.js'; import { withPMCredentials } from '../../pm/context.js'; import { createPMProvider, pmRegistry, withPMProvider } from '../../pm/index.js'; import type { AgentInput, CascadeConfig, ProjectConfig } from '../../types/index.js'; @@ -106,7 +107,10 @@ export async function triggerManualRun( project.id, project.pm?.type, (t) => pmRegistry.getOrNull(t), - () => withPMProvider(pmProvider, () => runAgent(input.agentType, agentInput)), + () => + withPMProvider(pmProvider, () => + withEmailIntegration(project.id, () => runAgent(input.agentType, agentInput)), + ), ); logger.info('Manual agent run completed', { projectId: input.projectId, diff --git a/tests/unit/email/client.test.ts b/tests/unit/email/client.test.ts new file mode 100644 index 00000000..601cee48 --- /dev/null +++ b/tests/unit/email/client.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { getEmailCredentials, withEmailCredentials } from '../../../src/email/client.js'; + +describe('email client credential scoping', () => { + describe('withEmailCredentials', () => { + it('makes credentials available inside the callback', async () => { + const creds = { + imapHost: 'imap.test.com', + imapPort: 993, + smtpHost: 'smtp.test.com', + smtpPort: 587, + username: 'test@test.com', + password: 'password123', + }; + + let capturedCreds: typeof creds | undefined; + await withEmailCredentials(creds, async () => { + capturedCreds = getEmailCredentials(); + }); + + expect(capturedCreds).toEqual(creds); + }); + + it('returns the result of the callback', async () => { + const creds = { + imapHost: 'imap.test.com', + imapPort: 993, + smtpHost: 'smtp.test.com', + smtpPort: 587, + username: 'test@test.com', + password: 'password123', + }; + + const result = await withEmailCredentials(creds, async () => 'test-result'); + expect(result).toBe('test-result'); + }); + + it('credentials are not available outside the callback', async () => { + const creds = { + imapHost: 'imap.test.com', + imapPort: 993, + smtpHost: 'smtp.test.com', + smtpPort: 587, + username: 'test@test.com', + password: 'password123', + }; + + await withEmailCredentials(creds, async () => {}); + + expect(() => getEmailCredentials()).toThrow( + 'No email credentials in scope. Wrap the call with withEmailCredentials()', + ); + }); + }); + + describe('getEmailCredentials', () => { + it('throws when no credentials are in scope', () => { + expect(() => getEmailCredentials()).toThrow( + 'No email credentials in scope. Wrap the call with withEmailCredentials()', + ); + }); + }); +}); diff --git a/tests/unit/email/integration.test.ts b/tests/unit/email/integration.test.ts new file mode 100644 index 00000000..78535bbc --- /dev/null +++ b/tests/unit/email/integration.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/config/provider.js', () => ({ + getIntegrationCredential: vi.fn(), +})); + +vi.mock('../../../src/utils/logging.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import { getIntegrationCredential } from '../../../src/config/provider.js'; +import { + hasEmailIntegration, + resolveEmailCredentials, + withEmailIntegration, +} from '../../../src/email/integration.js'; +import { logger } from '../../../src/utils/logging.js'; + +describe('email integration', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('resolveEmailCredentials', () => { + it('returns credentials when all fields are present', async () => { + vi.mocked(getIntegrationCredential).mockImplementation( + async (_projectId, _category, role) => { + const creds: Record = { + imap_host: 'imap.example.com', + imap_port: '993', + smtp_host: 'smtp.example.com', + smtp_port: '587', + username: 'user@example.com', + password: 'secret', + }; + return creds[role] ?? null; + }, + ); + + const result = await resolveEmailCredentials('project-1'); + + expect(result).toEqual({ + imapHost: 'imap.example.com', + imapPort: 993, + smtpHost: 'smtp.example.com', + smtpPort: 587, + username: 'user@example.com', + password: 'secret', + }); + }); + + it('returns null when a credential is missing', async () => { + vi.mocked(getIntegrationCredential).mockImplementation( + async (_projectId, _category, role) => { + if (role === 'password') return null; // Missing password + const creds: Record = { + imap_host: 'imap.example.com', + imap_port: '993', + smtp_host: 'smtp.example.com', + smtp_port: '587', + username: 'user@example.com', + }; + return creds[role] ?? null; + }, + ); + + const result = await resolveEmailCredentials('project-1'); + expect(result).toBeNull(); + }); + + it('returns null when port is not a valid number', async () => { + vi.mocked(getIntegrationCredential).mockImplementation( + async (_projectId, _category, role) => { + const creds: Record = { + imap_host: 'imap.example.com', + imap_port: 'invalid', + smtp_host: 'smtp.example.com', + smtp_port: '587', + username: 'user@example.com', + password: 'secret', + }; + return creds[role] ?? null; + }, + ); + + const result = await resolveEmailCredentials('project-1'); + expect(result).toBeNull(); + }); + + it('logs warning and returns null on error', async () => { + vi.mocked(getIntegrationCredential).mockRejectedValue(new Error('DB error')); + + const result = await resolveEmailCredentials('project-1'); + + expect(result).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + 'Failed to resolve email credentials', + expect.objectContaining({ + projectId: 'project-1', + error: 'DB error', + }), + ); + }); + }); + + describe('withEmailIntegration', () => { + it('runs function with credentials when available', async () => { + vi.mocked(getIntegrationCredential).mockImplementation( + async (_projectId, _category, role) => { + const creds: Record = { + imap_host: 'imap.example.com', + imap_port: '993', + smtp_host: 'smtp.example.com', + smtp_port: '587', + username: 'user@example.com', + password: 'secret', + }; + return creds[role] ?? null; + }, + ); + + const fn = vi.fn().mockResolvedValue('result'); + const result = await withEmailIntegration('project-1', fn); + + expect(fn).toHaveBeenCalled(); + expect(result).toBe('result'); + }); + + it('runs function without credentials when not configured', async () => { + vi.mocked(getIntegrationCredential).mockResolvedValue(null); + + const fn = vi.fn().mockResolvedValue('result'); + const result = await withEmailIntegration('project-1', fn); + + expect(fn).toHaveBeenCalled(); + expect(result).toBe('result'); + }); + }); + + describe('hasEmailIntegration', () => { + it('returns true when credentials are configured', async () => { + vi.mocked(getIntegrationCredential).mockImplementation( + async (_projectId, _category, role) => { + const creds: Record = { + imap_host: 'imap.example.com', + imap_port: '993', + smtp_host: 'smtp.example.com', + smtp_port: '587', + username: 'user@example.com', + password: 'secret', + }; + return creds[role] ?? null; + }, + ); + + const result = await hasEmailIntegration('project-1'); + expect(result).toBe(true); + }); + + it('returns false when credentials are not configured', async () => { + vi.mocked(getIntegrationCredential).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 new file mode 100644 index 00000000..25172744 --- /dev/null +++ b/tests/unit/gadgets/email/core.test.ts @@ -0,0 +1,292 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../src/email/client.js', () => ({ + sendEmail: vi.fn(), + searchEmails: vi.fn(), + readEmail: vi.fn(), + replyToEmail: vi.fn(), +})); + +vi.mock('../../../../src/utils/logging.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import { + readEmail as readEmailClient, + replyToEmail as replyToEmailClient, + searchEmails as searchEmailsClient, + sendEmail as sendEmailClient, +} from '../../../../src/email/client.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'; +import { sendEmail } from '../../../../src/gadgets/email/core/sendEmail.js'; +import { logger } from '../../../../src/utils/logging.js'; + +describe('email gadget core functions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('sendEmail', () => { + it('returns success message when email is sent', async () => { + vi.mocked(sendEmailClient).mockResolvedValue({ + messageId: '', + accepted: ['user@example.com'], + rejected: [], + }); + + const result = await sendEmail({ + to: ['user@example.com'], + subject: 'Test', + body: 'Hello', + }); + + expect(result).toBe( + 'Email sent successfully to user@example.com (Message-ID: )', + ); + }); + + it('includes rejected recipients in output', async () => { + vi.mocked(sendEmailClient).mockResolvedValue({ + messageId: '', + accepted: ['user@example.com'], + rejected: ['bad@example.com'], + }); + + const result = await sendEmail({ + to: ['user@example.com', 'bad@example.com'], + subject: 'Test', + body: 'Hello', + }); + + expect(result).toContain('rejected: bad@example.com'); + }); + + it('does not show empty rejected list', async () => { + vi.mocked(sendEmailClient).mockResolvedValue({ + messageId: '', + accepted: ['user@example.com'], + rejected: [], + }); + + const result = await sendEmail({ + to: ['user@example.com'], + subject: 'Test', + body: 'Hello', + }); + + expect(result).not.toContain('rejected'); + }); + + it('returns error message and logs on failure', async () => { + vi.mocked(sendEmailClient).mockRejectedValue(new Error('SMTP connection failed')); + + const result = await sendEmail({ + to: ['user@example.com'], + subject: 'Test', + body: 'Hello', + }); + + expect(result).toBe('Error sending email: SMTP connection failed'); + expect(logger.error).toHaveBeenCalledWith( + 'Email send failed', + expect.objectContaining({ error: 'SMTP connection failed' }), + ); + }); + }); + + describe('searchEmails', () => { + it('returns formatted results when emails found', async () => { + vi.mocked(searchEmailsClient).mockResolvedValue([ + { + uid: 123, + date: new Date('2024-01-15'), + from: 'sender@example.com', + to: ['recipient@example.com'], + subject: 'Important message', + snippet: 'Hello...', + }, + { + uid: 124, + date: new Date('2024-01-16'), + from: 'another@example.com', + to: ['recipient@example.com'], + subject: 'Follow up', + snippet: 'Regarding...', + }, + ]); + + const result = await searchEmails('INBOX', {}, 10); + + expect(result).toContain('Found 2 email(s)'); + expect(result).toContain('1. [UID:123]'); + expect(result).toContain('2. [UID:124]'); + expect(result).toContain('Important message'); + expect(result).toContain('sender@example.com'); + }); + + it('returns message when no emails found', async () => { + vi.mocked(searchEmailsClient).mockResolvedValue([]); + + const result = await searchEmails('INBOX', { from: 'nobody@example.com' }, 10); + + expect(result).toBe('No emails found matching the search criteria.'); + }); + + it('returns error message and logs on failure', async () => { + vi.mocked(searchEmailsClient).mockRejectedValue(new Error('IMAP timeout')); + + const result = await searchEmails('INBOX', {}, 10); + + expect(result).toBe('Error searching emails: IMAP timeout'); + expect(logger.error).toHaveBeenCalledWith( + 'Email search failed', + expect.objectContaining({ error: 'IMAP timeout' }), + ); + }); + }); + + describe('readEmail', () => { + it('returns formatted email content', async () => { + vi.mocked(readEmailClient).mockResolvedValue({ + uid: 123, + messageId: '', + date: new Date('2024-01-15T10:30:00Z'), + from: 'sender@example.com', + to: ['recipient@example.com'], + cc: [], + subject: 'Test Subject', + textBody: 'This is the body text.', + attachments: [], + references: [], + }); + + const result = await readEmail('INBOX', 123); + + expect(result).toContain('From: sender@example.com'); + expect(result).toContain('To: recipient@example.com'); + expect(result).toContain('Subject: Test Subject'); + expect(result).toContain('This is the body text.'); + }); + + it('shows CC when present', async () => { + vi.mocked(readEmailClient).mockResolvedValue({ + uid: 123, + messageId: '', + date: new Date('2024-01-15'), + from: 'sender@example.com', + to: ['recipient@example.com'], + cc: ['cc@example.com'], + subject: 'Test', + textBody: 'Body', + attachments: [], + references: [], + }); + + const result = await readEmail('INBOX', 123); + + expect(result).toContain('CC: cc@example.com'); + }); + + it('shows HTML body when text body is empty', async () => { + vi.mocked(readEmailClient).mockResolvedValue({ + uid: 123, + messageId: '', + date: new Date('2024-01-15'), + from: 'sender@example.com', + to: ['recipient@example.com'], + cc: [], + subject: 'Test', + textBody: '', + htmlBody: '

HTML content

', + attachments: [], + references: [], + }); + + const result = await readEmail('INBOX', 123); + + expect(result).toContain('--- Body (HTML) ---'); + expect(result).toContain('

HTML content

'); + }); + + it('shows attachments when present', async () => { + vi.mocked(readEmailClient).mockResolvedValue({ + uid: 123, + messageId: '', + date: new Date('2024-01-15'), + from: 'sender@example.com', + to: ['recipient@example.com'], + cc: [], + subject: 'Test', + textBody: 'Body', + attachments: [{ filename: 'doc.pdf', contentType: 'application/pdf', size: 1024 }], + references: [], + }); + + const result = await readEmail('INBOX', 123); + + expect(result).toContain('Attachments: doc.pdf'); + expect(result).toContain('application/pdf'); + }); + + it('returns error message and logs on failure', async () => { + vi.mocked(readEmailClient).mockRejectedValue(new Error('Email not found')); + + const result = await readEmail('INBOX', 999); + + expect(result).toBe('Error reading email: Email not found'); + expect(logger.error).toHaveBeenCalledWith( + 'Email read failed', + expect.objectContaining({ uid: 999, error: 'Email not found' }), + ); + }); + }); + + describe('replyToEmail', () => { + it('returns success message when reply is sent', async () => { + vi.mocked(replyToEmailClient).mockResolvedValue({ + messageId: '', + accepted: ['sender@example.com'], + rejected: [], + }); + + const result = await replyToEmail('INBOX', 123, 'Thanks!', false); + + expect(result).toBe('Reply sent to sender@example.com (Message-ID: )'); + }); + + it('includes rejected recipients in output', async () => { + vi.mocked(replyToEmailClient).mockResolvedValue({ + messageId: '', + accepted: ['sender@example.com'], + rejected: ['bad@example.com'], + }); + + const result = await replyToEmail('INBOX', 123, 'Thanks!', true); + + expect(result).toContain('rejected: bad@example.com'); + }); + + it('returns error message and logs on failure', async () => { + vi.mocked(replyToEmailClient).mockRejectedValue(new Error('Connection refused')); + + const result = await replyToEmail('INBOX', 123, 'Reply body', false); + + expect(result).toBe('Error sending reply: Connection refused'); + expect(logger.error).toHaveBeenCalledWith( + 'Email reply failed', + expect.objectContaining({ + uid: 123, + replyAll: false, + error: 'Connection refused', + }), + ); + }); + }); +}); diff --git a/tests/unit/gadgets/fileInsertContent.test.ts b/tests/unit/gadgets/fileInsertContent.test.ts index b9fbdad4..e262d7f3 100644 --- a/tests/unit/gadgets/fileInsertContent.test.ts +++ b/tests/unit/gadgets/fileInsertContent.test.ts @@ -3,6 +3,11 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +// Mock pathValidation to allow temp directory paths in tests +vi.mock('../../../src/gadgets/shared/pathValidation.js', () => ({ + validatePath: vi.fn((path: string) => path), +})); + // Mock readTracking so we don't have to pre-mark files vi.mock('../../../src/gadgets/readTracking.js', () => ({ assertFileRead: vi.fn(), // No-op — skip read guard diff --git a/tests/unit/gadgets/fileRemoveContent.test.ts b/tests/unit/gadgets/fileRemoveContent.test.ts index f8846cf9..aab7256c 100644 --- a/tests/unit/gadgets/fileRemoveContent.test.ts +++ b/tests/unit/gadgets/fileRemoveContent.test.ts @@ -3,6 +3,11 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +// Mock pathValidation to allow temp directory paths in tests +vi.mock('../../../src/gadgets/shared/pathValidation.js', () => ({ + validatePath: vi.fn((path: string) => path), +})); + // Mock readTracking so we don't have to pre-mark files vi.mock('../../../src/gadgets/readTracking.js', () => ({ assertFileRead: vi.fn(), // No-op — skip read guard diff --git a/tests/unit/triggers/manual-runner.test.ts b/tests/unit/triggers/manual-runner.test.ts index ea9aa26f..62ef21a5 100644 --- a/tests/unit/triggers/manual-runner.test.ts +++ b/tests/unit/triggers/manual-runner.test.ts @@ -32,6 +32,10 @@ vi.mock('../../../src/pm/context.js', () => ({ ), })); +vi.mock('../../../src/email/integration.js', () => ({ + withEmailIntegration: vi.fn((_projectId: string, fn: () => unknown) => fn()), +})); + import { runAgent } from '../../../src/agents/registry.js'; import { getRunById } from '../../../src/db/repositories/runsRepository.js'; import { withPMCredentials } from '../../../src/pm/context.js';