diff --git a/src/email/client.ts b/src/email/client.ts deleted file mode 100644 index e71fafbf..00000000 --- a/src/email/client.ts +++ /dev/null @@ -1,528 +0,0 @@ -/** - * Email client with AsyncLocalStorage-based credential scoping. - * - * Uses imapflow for IMAP operations and nodemailer for SMTP. - * Credentials are scoped per-request via withEmailCredentials(). - */ - -import { AsyncLocalStorage } from 'node:async_hooks'; -import { ImapFlow } from 'imapflow'; -import nodemailer from 'nodemailer'; -import type { Transporter } from 'nodemailer'; -import { logger } from '../utils/logging.js'; -import { replyViaGmailApi, sendViaGmailApi } from './gmail/send.js'; -import type { - EmailCredentials, - EmailMessage, - EmailSearchCriteria, - EmailSummary, - ReplyEmailOptions, - SendEmailOptions, - SendEmailResult, -} from './types.js'; - -const emailCredentialStore = new AsyncLocalStorage(); - -/** - * 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; -} - -/** - * 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, - 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. - * 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, - }); -} - -/** - * 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. Uses Gmail REST API for OAuth accounts (avoids SMTP port 465 - * being blocked in container environments); falls back to SMTP for password accounts. - */ -export async function sendEmail(options: SendEmailOptions): Promise { - const creds = getEmailCredentials(); - - if (creds.authMethod === 'oauth') { - logger.debug('Sending email via Gmail API', { to: options.to, subject: options.subject }); - return sendViaGmailApi(options, creds.accessToken, creds.email); - } - - const fromEmail = getEmailAddress(); - const transport = createSmtpTransport(); - - try { - logger.debug('Sending email via SMTP', { - to: options.to, - subject: options.subject, - }); - - const result = await transport.sendMail({ - from: fromEmail, - 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: Array.isArray(result.accepted) - ? result.accepted.filter((a: unknown): a is string => typeof a === 'string') - : [], - rejected: Array.isArray(result.rejected) - ? result.rejected.filter((r: unknown): r is string => typeof r === 'string') - : [], - }; - } finally { - await transport.close(); - } -} - -/** - * Mark an email as seen (read) in the mailbox. - */ -export async function markEmailAsSeen(folder: string, uid: number): 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 (IMAP — unchanged) - const original = await readEmail(options.folder, options.uid); - - const creds = getEmailCredentials(); - - if (creds.authMethod === 'oauth') { - logger.debug('Sending reply via Gmail API', { uid: options.uid, replyAll: options.replyAll }); - return replyViaGmailApi(options, original, creds.accessToken, creds.email); - } - - // 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) - recipients.push(original.from); - 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); - } - - // 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 via SMTP - const transport = createSmtpTransport(); - - try { - logger.debug('Sending reply via SMTP', { - to: recipients, - subject, - inReplyTo: original.messageId, - }); - - const result = await transport.sendMail({ - from: fromEmail, - 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: Array.isArray(result.accepted) - ? result.accepted.filter((a: unknown): a is string => 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/context.ts b/src/email/context.ts new file mode 100644 index 00000000..53d0c867 --- /dev/null +++ b/src/email/context.ts @@ -0,0 +1,29 @@ +/** + * AsyncLocalStorage-based scoping for the active EmailProvider. + * + * Webhook handlers / integration wrappers call withEmailProvider(provider, fn) + * to make the provider available to all downstream gadget code via getEmailProvider(). + */ + +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { EmailProvider } from './provider.js'; + +const emailProviderStore = new AsyncLocalStorage(); + +export function withEmailProvider(provider: EmailProvider, fn: () => Promise): Promise { + return emailProviderStore.run(provider, fn); +} + +export function getEmailProvider(): EmailProvider { + const provider = emailProviderStore.getStore(); + if (!provider) { + throw new Error( + 'No EmailProvider in scope. Wrap the call with withEmailProvider() or ensure per-project email credentials are set in the database.', + ); + } + return provider; +} + +export function getEmailProviderOrNull(): EmailProvider | null { + return emailProviderStore.getStore() ?? null; +} diff --git a/src/email/gmail/adapter.ts b/src/email/gmail/adapter.ts new file mode 100644 index 00000000..a03185c8 --- /dev/null +++ b/src/email/gmail/adapter.ts @@ -0,0 +1,171 @@ +/** + * GmailEmailProvider — EmailProvider backed by IMAP (read) + Gmail REST API (send). + * + * Uses the same IMAP helpers as ImapEmailProvider for reading, but routes all + * outbound mail through the Gmail REST API to avoid SMTP port 465 being blocked + * in container environments. + */ + +import { logger } from '../../utils/logging.js'; +import { + buildSearchQuery, + createImapClient, + extractAttachments, + parseAddress, + parseAddresses, + parseEmailBody, + parseThreadingHeaders, +} from '../imap/utils.js'; +import type { EmailProvider } from '../provider.js'; +import type { + EmailMessage, + EmailSearchCriteria, + EmailSummary, + OAuthEmailCredentials, + ReplyEmailOptions, + SendEmailOptions, + SendEmailResult, +} from '../types.js'; +import { replyViaGmailApi, sendViaGmailApi } from './send.js'; + +export class GmailEmailProvider implements EmailProvider { + readonly type = 'gmail'; + + constructor(private readonly creds: OAuthEmailCredentials) {} + + async searchEmails( + folder: string, + criteria: EmailSearchCriteria, + maxResults: number, + ): Promise { + const client = createImapClient(this.creds); + + 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 }); + + const limitedUids = searchResult.slice(-maxResults).reverse(); + + 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(); + } + } + + async readEmail(folder: string, uid: number): Promise { + const client = createImapClient(this.creds); + + 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(); + } + } + + async sendEmail(options: SendEmailOptions): Promise { + logger.debug('Sending email via Gmail API', { to: options.to, subject: options.subject }); + return sendViaGmailApi(options, this.creds.accessToken, this.creds.email); + } + + async replyToEmail(options: ReplyEmailOptions): Promise { + const original = await this.readEmail(options.folder, options.uid); + logger.debug('Sending reply via Gmail API', { uid: options.uid, replyAll: options.replyAll }); + return replyViaGmailApi(options, original, this.creds.accessToken, this.creds.email); + } + + async markEmailAsSeen(folder: string, uid: number): Promise { + const client = createImapClient(this.creds); + + 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(); + } + } +} diff --git a/src/email/gmail/integration.ts b/src/email/gmail/integration.ts new file mode 100644 index 00000000..0cddd6aa --- /dev/null +++ b/src/email/gmail/integration.ts @@ -0,0 +1,82 @@ +/** + * GmailIntegration — resolves OAuth credentials from the DB, exchanges a refresh + * token for an access token, and scopes a GmailEmailProvider for the callback. + */ + +import { getIntegrationCredentialOrNull, getOrgCredential } from '../../config/provider.js'; +import { logger } from '../../utils/logging.js'; +import { withEmailProvider } from '../context.js'; +import type { EmailIntegration } from '../provider.js'; +import { GmailEmailProvider } from './adapter.js'; +import { getGmailAccessToken } from './oauth.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; + +export class GmailIntegration implements EmailIntegration { + readonly type = 'gmail'; + + async withCredentials(projectId: string, fn: () => Promise): Promise { + const creds = await this.resolveCredentials(projectId); + if (!creds) { + return fn(); + } + return withEmailProvider(new GmailEmailProvider(creds), fn); + } + + async hasCredentials(projectId: string): Promise { + const creds = await this.resolveCredentials(projectId); + return creds !== null; + } + + private async resolveCredentials(projectId: string) { + 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; + } + + 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 { + const accessToken = await getGmailAccessToken( + clientId, + clientSecret, + refreshToken, + gmailEmail, + ); + + return { + authMethod: 'oauth' as const, + 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; + } + } +} diff --git a/src/email/imap/adapter.ts b/src/email/imap/adapter.ts new file mode 100644 index 00000000..9f19c510 --- /dev/null +++ b/src/email/imap/adapter.ts @@ -0,0 +1,271 @@ +/** + * ImapEmailProvider — EmailProvider backed by IMAP (read) + nodemailer SMTP (send). + * + * Used for standard password-authenticated email accounts. + */ + +import nodemailer from 'nodemailer'; +import type { Transporter } from 'nodemailer'; +import { logger } from '../../utils/logging.js'; +import type { EmailProvider } from '../provider.js'; +import type { + EmailMessage, + EmailSearchCriteria, + EmailSummary, + PasswordEmailCredentials, + ReplyEmailOptions, + SendEmailOptions, + SendEmailResult, +} from '../types.js'; +import { + buildSearchQuery, + createImapClient, + extractAttachments, + parseAddress, + parseAddresses, + parseEmailBody, + parseThreadingHeaders, +} from './utils.js'; + +export class ImapEmailProvider implements EmailProvider { + readonly type = 'imap'; + + constructor(private readonly creds: PasswordEmailCredentials) {} + + private buildSmtpTransport(): Transporter { + return nodemailer.createTransport({ + host: this.creds.smtpHost, + port: this.creds.smtpPort, + secure: this.creds.smtpPort === 465, + auth: { + user: this.creds.username, + pass: this.creds.password, + }, + }); + } + + async searchEmails( + folder: string, + criteria: EmailSearchCriteria, + maxResults: number, + ): Promise { + const client = createImapClient(this.creds); + + 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 }); + + const limitedUids = searchResult.slice(-maxResults).reverse(); + + 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(); + } + } + + async readEmail(folder: string, uid: number): Promise { + const client = createImapClient(this.creds); + + 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(); + } + } + + async sendEmail(options: SendEmailOptions): Promise { + const transport = this.buildSmtpTransport(); + + try { + logger.debug('Sending email via SMTP', { to: options.to, subject: options.subject }); + + const result = await transport.sendMail({ + from: this.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: Array.isArray(result.accepted) + ? result.accepted.filter((a: unknown): a is string => typeof a === 'string') + : [], + rejected: Array.isArray(result.rejected) + ? result.rejected.filter((r: unknown): r is string => typeof r === 'string') + : [], + }; + } finally { + await transport.close(); + } + } + + async replyToEmail(options: ReplyEmailOptions): Promise { + const original = await this.readEmail(options.folder, options.uid); + + const fromEmail = this.creds.username; + const selfEmailLower = fromEmail.toLowerCase(); + + const recipients: string[] = []; + if (options.replyAll) { + recipients.push(original.from); + recipients.push( + ...original.to.filter((addr) => !addr.toLowerCase().includes(selfEmailLower)), + ); + recipients.push( + ...original.cc.filter((addr) => !addr.toLowerCase().includes(selfEmailLower)), + ); + } else { + recipients.push(original.from); + } + + const subject = original.subject.startsWith('Re:') + ? original.subject + : `Re: ${original.subject}`; + + const references = [...original.references]; + if (original.messageId && !references.includes(original.messageId)) { + references.push(original.messageId); + } + + const transport = this.buildSmtpTransport(); + + try { + logger.debug('Sending reply via SMTP', { + to: recipients, + subject, + inReplyTo: original.messageId, + }); + + const result = await transport.sendMail({ + from: fromEmail, + 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: Array.isArray(result.accepted) + ? result.accepted.filter((a: unknown): a is string => typeof a === 'string') + : [], + rejected: Array.isArray(result.rejected) + ? result.rejected.filter((r: unknown): r is string => typeof r === 'string') + : [], + }; + } finally { + await transport.close(); + } + } + + async markEmailAsSeen(folder: string, uid: number): Promise { + const client = createImapClient(this.creds); + + 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(); + } + } +} diff --git a/src/email/imap/integration.ts b/src/email/imap/integration.ts new file mode 100644 index 00000000..28a74b76 --- /dev/null +++ b/src/email/imap/integration.ts @@ -0,0 +1,64 @@ +/** + * ImapIntegration — resolves password-based IMAP/SMTP credentials from the DB + * and scopes an ImapEmailProvider for the duration of the callback. + */ + +import { getIntegrationCredentialOrNull } from '../../config/provider.js'; +import { logger } from '../../utils/logging.js'; +import { withEmailProvider } from '../context.js'; +import type { EmailIntegration } from '../provider.js'; +import { ImapEmailProvider } from './adapter.js'; + +export class ImapIntegration implements EmailIntegration { + readonly type = 'imap'; + + async withCredentials(projectId: string, fn: () => Promise): Promise { + const creds = await this.resolveCredentials(projectId); + if (!creds) { + return fn(); + } + return withEmailProvider(new ImapEmailProvider(creds), fn); + } + + async hasCredentials(projectId: string): Promise { + const creds = await this.resolveCredentials(projectId); + return creds !== null; + } + + private async resolveCredentials(projectId: string) { + 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'), + ]); + + 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)) { + logger.warn('Invalid IMAP/SMTP port in email credentials — skipping provider', { + projectId, + imapPort: imapPortStr, + smtpPort: smtpPortStr, + }); + return null; + } + + return { + authMethod: 'password' as const, + imapHost, + imapPort, + smtpHost, + smtpPort, + username, + password, + }; + } +} diff --git a/src/email/imap/utils.ts b/src/email/imap/utils.ts new file mode 100644 index 00000000..d2340925 --- /dev/null +++ b/src/email/imap/utils.ts @@ -0,0 +1,179 @@ +/** + * Shared IMAP/MIME helpers used by both ImapEmailProvider and GmailEmailProvider. + */ + +import { ImapFlow } from 'imapflow'; +import type { EmailAttachment, EmailSearchCriteria } from '../types.js'; +import type { EmailCredentials } from '../types.js'; + +/** + * Create an ImapFlow client configured with the given credentials. + * Supports both password and OAuth (XOAUTH2) authentication. + */ +export function createImapClient(creds: EmailCredentials): ImapFlow { + 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, + auth, + logger: false, + connectionTimeout: 30000, + greetingTimeout: 15000, + socketTimeout: 60000, + }); +} + +/** + * Parse an email address object/string into a simple string. + */ +export 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. + */ +export 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. + */ +export 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. + */ +export 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 }; +} + +/** + * Parse threading headers from raw email source. + */ +export 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, + }; +} + +/** + * 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. + */ +export function extractAttachments(bodyStruct: unknown): EmailAttachment[] { + const attachments: EmailAttachment[] = []; + + if (!bodyStruct || typeof bodyStruct !== 'object') { + return attachments; + } + + 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; +} diff --git a/src/email/index.ts b/src/email/index.ts index cb399731..ae916570 100644 --- a/src/email/index.ts +++ b/src/email/index.ts @@ -1,28 +1,37 @@ -// Email client and credential scoping -export { - withEmailCredentials, - getEmailCredentials, - searchEmails, - readEmail, - sendEmail, - replyToEmail, -} from './client.js'; +/** + * Email module barrel. + * + * Registers all provider adapters at import time (mirrors pm/index.ts). + * Consumers import from here to get both the public API and side-effect registration. + */ + +import { GmailIntegration } from './gmail/integration.js'; +import { ImapIntegration } from './imap/integration.js'; +import { emailRegistry } from './registry.js'; + +emailRegistry.register(new ImapIntegration()); +emailRegistry.register(new GmailIntegration()); + +// Provider scoping +export { getEmailProvider, getEmailProviderOrNull, withEmailProvider } from './context.js'; // Integration credential resolution -export { - resolveEmailCredentials, - withEmailIntegration, - hasEmailIntegration, -} from './integration.js'; +export { hasEmailIntegration, withEmailIntegration } from './integration.js'; + +// Registry (for advanced use cases) +export { emailRegistry } from './registry.js'; + +// Interfaces +export type { EmailIntegration, EmailProvider } from './provider.js'; // Types export type { + EmailAttachment, EmailCredentials, + EmailMessage, EmailSearchCriteria, EmailSummary, - EmailMessage, - EmailAttachment, + ReplyEmailOptions, SendEmailOptions, SendEmailResult, - ReplyEmailOptions, } from './types.js'; diff --git a/src/email/integration.ts b/src/email/integration.ts index cc658810..5d5ea640 100644 --- a/src/email/integration.ts +++ b/src/email/integration.ts @@ -1,159 +1,49 @@ /** - * Email integration — credential resolution and scoping. + * Email integration — credential resolution and scoping via the registry. * - * Provides withEmailIntegration() for establishing email credential scope - * similar to withPMCredentials() for PM integrations. - * - * Supports both IMAP (password) and Gmail (OAuth) authentication. + * Delegates to the registered EmailIntegration for the project's configured + * email provider. Adding a new provider requires only a new class + one registry + * line in index.ts — no changes here. */ -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 { 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, - }; -} +import { emailRegistry } from './registry.js'; /** - * 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. + * Run a function with an EmailProvider in scope for the given project. + * + * If no email integration is configured (or the provider is unknown), runs + * fn() without establishing a provider scope — gadgets will fail with a clear + * error if they try to call getEmailProvider(). */ -export async function resolveEmailCredentials(projectId: string): Promise { +export async function withEmailIntegration(projectId: string, fn: () => Promise): Promise { try { - // 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; - } - - if (provider === 'gmail') { - return resolveGmailCredentials(projectId); - } - - // Default to IMAP password auth - return resolveImapCredentials(projectId); + const providerType = await getIntegrationProvider(projectId, 'email'); + if (!providerType) return fn(); + const integration = emailRegistry.getOrNull(providerType); + if (!integration) return fn(); + return integration.withCredentials(projectId, fn); } catch (error) { - logger.warn('Failed to resolve email credentials', { + logger.warn('Failed to resolve email integration, running without 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. + * Check if email integration is configured and credentials are present. */ export async function hasEmailIntegration(projectId: string): Promise { - const creds = await resolveEmailCredentials(projectId); - return creds !== null; + try { + const providerType = await getIntegrationProvider(projectId, 'email'); + if (!providerType) return false; + const integration = emailRegistry.getOrNull(providerType); + if (!integration) return false; + return integration.hasCredentials(projectId); + } catch { + return false; + } } diff --git a/src/email/provider.ts b/src/email/provider.ts new file mode 100644 index 00000000..08142cdc --- /dev/null +++ b/src/email/provider.ts @@ -0,0 +1,39 @@ +/** + * EmailProvider and EmailIntegration interfaces. + * + * EmailProvider — the runtime object that performs email operations + * (IMAP reads, SMTP/API sends) for a specific auth method. + * + * EmailIntegration — knows how to resolve credentials for a project + * and scope an EmailProvider via AsyncLocalStorage. + */ + +import type { + EmailMessage, + EmailSearchCriteria, + EmailSummary, + ReplyEmailOptions, + SendEmailOptions, + SendEmailResult, +} from './types.js'; + +export interface EmailProvider { + readonly type: string; // 'imap' | 'gmail' + searchEmails( + folder: string, + criteria: EmailSearchCriteria, + maxResults: number, + ): Promise; + readEmail(folder: string, uid: number): Promise; + sendEmail(options: SendEmailOptions): Promise; + replyToEmail(options: ReplyEmailOptions): Promise; + markEmailAsSeen(folder: string, uid: number): Promise; +} + +export interface EmailIntegration { + readonly type: string; // matches project_integrations.provider + /** Resolve credentials from DB and run fn inside provider scope */ + withCredentials(projectId: string, fn: () => Promise): Promise; + /** True if all required credentials are present */ + hasCredentials(projectId: string): Promise; +} diff --git a/src/email/registry.ts b/src/email/registry.ts new file mode 100644 index 00000000..c895158e --- /dev/null +++ b/src/email/registry.ts @@ -0,0 +1,34 @@ +/** + * EmailIntegrationRegistry — singleton that holds all registered email integrations. + * + * Populated at import time by each integration module. The gadgets and webhook + * handlers use `emailRegistry.getOrNull(type)` to obtain the integration instance + * without provider-specific branching. + */ + +import type { EmailIntegration } from './provider.js'; + +class EmailIntegrationRegistry { + private integrations = new Map(); + + register(integration: EmailIntegration): void { + this.integrations.set(integration.type, integration); + } + + get(type: string): EmailIntegration { + const integration = this.integrations.get(type); + if (!integration) { + throw new Error( + `Unknown email integration type: '${type}'. Registered: ${[...this.integrations.keys()].join(', ')}`, + ); + } + return integration; + } + + getOrNull(type: string): EmailIntegration | null { + return this.integrations.get(type) ?? null; + } +} + +/** Singleton registry, populated at import time */ +export const emailRegistry = new EmailIntegrationRegistry(); diff --git a/src/gadgets/email/core/markEmailAsSeen.ts b/src/gadgets/email/core/markEmailAsSeen.ts index 325da3b3..a8e73579 100644 --- a/src/gadgets/email/core/markEmailAsSeen.ts +++ b/src/gadgets/email/core/markEmailAsSeen.ts @@ -1,17 +1,17 @@ -import { markEmailAsSeen as markEmailAsSeenClient } from '../../../email/client.js'; +import { getEmailProvider } from '../../../email/context.js'; import { logger } from '../../../utils/logging.js'; export async function markEmailAsSeen(folder: string, uid: number): Promise { try { - await markEmailAsSeenClient(folder, uid); + await getEmailProvider().markEmailAsSeen(folder, uid); return `Email (UID: ${uid}) in folder "${folder}" has been marked as seen/read.`; } catch (error) { + const message = error instanceof Error ? error.message : String(error); logger.error('Mark email as seen failed', { folder, uid, - error: error instanceof Error ? error.message : String(error), + error: message, }); - const message = error instanceof Error ? error.message : String(error); return `Error marking email as seen: ${message}`; } } diff --git a/src/gadgets/email/core/readEmail.ts b/src/gadgets/email/core/readEmail.ts index 48bc709d..dee5987d 100644 --- a/src/gadgets/email/core/readEmail.ts +++ b/src/gadgets/email/core/readEmail.ts @@ -1,9 +1,9 @@ -import { readEmail as readEmailClient } from '../../../email/client.js'; +import { getEmailProvider } from '../../../email/context.js'; import { logger } from '../../../utils/logging.js'; export async function readEmail(folder: string, uid: number): Promise { try { - const email = await readEmailClient(folder, uid); + const email = await getEmailProvider().readEmail(folder, uid); const lines: string[] = [`From: ${email.from}`, `To: ${email.to.join(', ')}`]; @@ -28,16 +28,18 @@ export async function readEmail(folder: string, uid: number): Promise { lines.push('', '--- Body (Text) ---', '', email.textBody); } else if (email.htmlBody) { lines.push('', '--- Body (HTML) ---', '', email.htmlBody); + } else { + lines.push('', '(no body)'); } return lines.join('\n'); } catch (error) { + const message = error instanceof Error ? error.message : String(error); logger.error('Email read failed', { folder, uid, - error: error instanceof Error ? error.message : String(error), + error: message, }); - 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 index e6e7d938..f66f7f02 100644 --- a/src/gadgets/email/core/replyToEmail.ts +++ b/src/gadgets/email/core/replyToEmail.ts @@ -1,4 +1,4 @@ -import { replyToEmail as replyToEmailClient } from '../../../email/client.js'; +import { getEmailProvider } from '../../../email/context.js'; import { logger } from '../../../utils/logging.js'; export async function replyToEmail( @@ -8,20 +8,23 @@ export async function replyToEmail( replyAll: boolean, ): Promise { try { - const result = await replyToEmailClient({ folder, uid, body, replyAll }); + const result = await getEmailProvider().replyToEmail({ folder, uid, body, replyAll }); const accepted = result.accepted.join(', '); const rejected = result.rejected.length > 0 ? ` (rejected: ${result.rejected.join(', ')})` : ''; + if (!accepted) { + return `Email delivery failed — all recipients rejected${rejected} (Message-ID: ${result.messageId})`; + } return `Reply sent to ${accepted}${rejected} (Message-ID: ${result.messageId})`; } catch (error) { + const message = error instanceof Error ? error.message : String(error); logger.error('Email reply failed', { folder, uid, replyAll, - error: error instanceof Error ? error.message : String(error), + error: message, }); - 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 index 78e3012e..81a839a1 100644 --- a/src/gadgets/email/core/searchEmails.ts +++ b/src/gadgets/email/core/searchEmails.ts @@ -1,4 +1,4 @@ -import { searchEmails as searchEmailsClient } from '../../../email/client.js'; +import { getEmailProvider } from '../../../email/context.js'; import type { EmailSearchCriteria } from '../../../email/types.js'; import { logger } from '../../../utils/logging.js'; @@ -8,13 +8,13 @@ export async function searchEmails( maxResults: number, ): Promise { try { - const results = await searchEmailsClient(folder, criteria, maxResults); + const results = await getEmailProvider().searchEmails(folder, criteria, maxResults); if (results.length === 0) { return 'No emails found matching the search criteria.'; } - const lines: string[] = [`Found ${results.length} email(s):\n`]; + const lines: string[] = [`Found ${results.length} email(s):`, '']; results.forEach((email, index) => { const dateStr = email.date.toISOString().split('T')[0]; @@ -25,12 +25,12 @@ export async function searchEmails( return lines.join('\n'); } catch (error) { + const message = error instanceof Error ? error.message : String(error); logger.error('Email search failed', { folder, criteria, - error: error instanceof Error ? error.message : String(error), + error: message, }); - 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 index a3ac851f..23a89d21 100644 --- a/src/gadgets/email/core/sendEmail.ts +++ b/src/gadgets/email/core/sendEmail.ts @@ -1,22 +1,25 @@ -import { sendEmail as sendEmailClient } from '../../../email/client.js'; +import { getEmailProvider } from '../../../email/context.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 result = await getEmailProvider().sendEmail(options); const accepted = result.accepted.join(', '); const rejected = result.rejected.length > 0 ? ` (rejected: ${result.rejected.join(', ')})` : ''; + if (!accepted) { + return `Email delivery failed — all recipients rejected${rejected} (Message-ID: ${result.messageId})`; + } return `Email sent successfully to ${accepted}${rejected} (Message-ID: ${result.messageId})`; } catch (error) { + const message = error instanceof Error ? error.message : String(error); logger.error('Email send failed', { to: options.to, subject: options.subject, - error: error instanceof Error ? error.message : String(error), + error: message, }); - const message = error instanceof Error ? error.message : String(error); return `Error sending email: ${message}`; } } diff --git a/src/pm/webhook-handler.ts b/src/pm/webhook-handler.ts index f737dc40..1a4ad0fc 100644 --- a/src/pm/webhook-handler.ts +++ b/src/pm/webhook-handler.ts @@ -7,7 +7,7 @@ * ack comment management) is delegated to the PMIntegration interface. */ -import { withEmailIntegration } from '../email/integration.js'; +import { withEmailIntegration } from '../email/index.js'; import { withGitHubToken } from '../github/client.js'; import { getPersonaToken } from '../github/personas.js'; import type { TriggerRegistry } from '../triggers/registry.js'; diff --git a/src/triggers/github/webhook-handler.ts b/src/triggers/github/webhook-handler.ts index 3c960c2d..57b4bee3 100644 --- a/src/triggers/github/webhook-handler.ts +++ b/src/triggers/github/webhook-handler.ts @@ -1,6 +1,6 @@ import { INITIAL_MESSAGES } from '../../config/agentMessages.js'; import { loadProjectConfigByRepo } from '../../config/provider.js'; -import { withEmailIntegration } from '../../email/integration.js'; +import { withEmailIntegration } from '../../email/index.js'; import { getSessionState } from '../../gadgets/sessionState.js'; import { githubClient, withGitHubToken } from '../../github/client.js'; import { getPersonaToken, resolvePersonaIdentities } from '../../github/personas.js'; diff --git a/src/triggers/shared/integration-validation.ts b/src/triggers/shared/integration-validation.ts index 2341c0aa..dcca43a3 100644 --- a/src/triggers/shared/integration-validation.ts +++ b/src/triggers/shared/integration-validation.ts @@ -8,7 +8,7 @@ import { loadAgentDefinition } from '../../agents/definitions/loader.js'; import type { AgentIntegrations, IntegrationCategory } from '../../agents/definitions/schema.js'; -import { hasEmailIntegration } from '../../email/integration.js'; +import { hasEmailIntegration } from '../../email/index.js'; import { hasScmIntegration, hasScmPersonaToken } from '../../github/integration.js'; import { getPersonaForAgentType } from '../../github/personas.js'; import { hasPmIntegration } from '../../pm/integration.js'; diff --git a/src/triggers/shared/manual-runner.ts b/src/triggers/shared/manual-runner.ts index 507a47e0..bad64edb 100644 --- a/src/triggers/shared/manual-runner.ts +++ b/src/triggers/shared/manual-runner.ts @@ -2,7 +2,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 { withEmailIntegration } from '../../email/index.js'; import { withPMCredentials } from '../../pm/context.js'; import { createPMProvider, pmRegistry, withPMProvider } from '../../pm/index.js'; import type { AgentInput, CascadeConfig, ProjectConfig } from '../../types/index.js'; diff --git a/tests/integration/integration-validation.test.ts b/tests/integration/integration-validation.test.ts index 74bf1efc..0b3472b6 100644 --- a/tests/integration/integration-validation.test.ts +++ b/tests/integration/integration-validation.test.ts @@ -13,7 +13,7 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { hasEmailIntegration } from '../../src/email/integration.js'; +import { hasEmailIntegration } from '../../src/email/index.js'; import { hasScmIntegration, hasScmPersonaToken } from '../../src/github/integration.js'; import { hasPmIntegration } from '../../src/pm/integration.js'; import { diff --git a/tests/unit/email/client.test.ts b/tests/unit/email/client.test.ts deleted file mode 100644 index 601cee48..00000000 --- a/tests/unit/email/client.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -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/context.test.ts b/tests/unit/email/context.test.ts new file mode 100644 index 00000000..0e08f6d0 --- /dev/null +++ b/tests/unit/email/context.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { + getEmailProvider, + getEmailProviderOrNull, + withEmailProvider, +} from '../../../src/email/context.js'; +import type { EmailProvider } from '../../../src/email/provider.js'; + +function makeProvider(type = 'imap'): EmailProvider { + return { + type, + searchEmails: async () => [], + readEmail: async () => ({ + uid: 0, + messageId: '', + date: new Date(), + from: '', + to: [], + cc: [], + subject: '', + textBody: '', + attachments: [], + references: [], + }), + sendEmail: async () => ({ messageId: '', accepted: [], rejected: [] }), + replyToEmail: async () => ({ messageId: '', accepted: [], rejected: [] }), + markEmailAsSeen: async () => {}, + }; +} + +describe('email provider scoping', () => { + describe('withEmailProvider', () => { + it('makes provider available inside the callback', async () => { + const provider = makeProvider(); + + let captured: EmailProvider | undefined; + await withEmailProvider(provider, async () => { + captured = getEmailProvider(); + }); + + expect(captured).toBe(provider); + }); + + it('returns the result of the callback', async () => { + const provider = makeProvider(); + const result = await withEmailProvider(provider, async () => 'test-result'); + expect(result).toBe('test-result'); + }); + + it('provider is not available outside the callback', async () => { + const provider = makeProvider(); + await withEmailProvider(provider, async () => {}); + + expect(() => getEmailProvider()).toThrow('No EmailProvider in scope.'); + }); + }); + + describe('getEmailProvider', () => { + it('throws when no provider is in scope', () => { + expect(() => getEmailProvider()).toThrow('No EmailProvider in scope.'); + }); + }); + + describe('getEmailProviderOrNull', () => { + it('returns null when no provider is in scope', () => { + expect(getEmailProviderOrNull()).toBeNull(); + }); + + it('returns the provider when in scope', async () => { + const provider = makeProvider(); + let result: EmailProvider | null = null; + await withEmailProvider(provider, async () => { + result = getEmailProviderOrNull(); + }); + expect(result).toBe(provider); + }); + }); +}); diff --git a/tests/unit/email/gmail/adapter.test.ts b/tests/unit/email/gmail/adapter.test.ts new file mode 100644 index 00000000..fb6bfb5e --- /dev/null +++ b/tests/unit/email/gmail/adapter.test.ts @@ -0,0 +1,221 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../src/utils/logging.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +const { mockLock, mockClient } = vi.hoisted(() => { + const mockLock = { release: vi.fn() }; + const mockClient = { + connect: vi.fn(), + logout: vi.fn(), + getMailboxLock: vi.fn().mockResolvedValue(mockLock), + search: vi.fn(), + fetch: vi.fn(), + fetchOne: vi.fn(), + messageFlagsAdd: vi.fn(), + }; + return { mockLock, mockClient }; +}); + +vi.mock('imapflow', () => ({ + ImapFlow: vi.fn().mockImplementation(() => mockClient), +})); + +vi.mock('../../../../src/email/gmail/send.js', () => ({ + sendViaGmailApi: vi.fn(), + replyViaGmailApi: vi.fn(), +})); + +import { GmailEmailProvider } from '../../../../src/email/gmail/adapter.js'; +import { replyViaGmailApi, sendViaGmailApi } from '../../../../src/email/gmail/send.js'; +import type { OAuthEmailCredentials } from '../../../../src/email/types.js'; + +const testCreds: OAuthEmailCredentials = { + authMethod: 'oauth', + imapHost: 'imap.gmail.com', + imapPort: 993, + smtpHost: 'smtp.gmail.com', + smtpPort: 465, + email: 'user@gmail.com', + accessToken: 'access-token-123', +}; + +describe('GmailEmailProvider', () => { + let provider: GmailEmailProvider; + + beforeEach(() => { + vi.clearAllMocks(); + provider = new GmailEmailProvider(testCreds); + mockClient.connect.mockResolvedValue(undefined); + mockClient.logout.mockResolvedValue(undefined); + mockClient.getMailboxLock.mockResolvedValue(mockLock); + mockLock.release.mockReturnValue(undefined); + }); + + it('has type "gmail"', () => { + expect(provider.type).toBe('gmail'); + }); + + describe('searchEmails', () => { + it('returns summaries when messages are found', async () => { + mockClient.search.mockResolvedValue([1, 2]); + + const mockMessages = [ + { + uid: 2, + envelope: { + date: new Date('2024-01-15'), + from: [{ address: 'sender@example.com' }], + to: [{ address: 'user@gmail.com' }], + subject: 'Hello', + }, + source: Buffer.from('Hello body'), + }, + ]; + + async function* asyncGen() { + for (const msg of mockMessages) yield msg; + } + mockClient.fetch.mockReturnValue(asyncGen()); + + const result = await provider.searchEmails('INBOX', {}, 10); + + expect(result).toHaveLength(1); + expect(result[0].uid).toBe(2); + expect(result[0].subject).toBe('Hello'); + }); + + it('returns empty array when no messages match', async () => { + mockClient.search.mockResolvedValue([]); + + const result = await provider.searchEmails('INBOX', {}, 10); + + expect(result).toEqual([]); + }); + }); + + describe('readEmail', () => { + it('returns EmailMessage fields on success', async () => { + const mockDate = new Date('2024-03-01'); + mockClient.fetchOne.mockResolvedValue({ + uid: 7, + envelope: { + date: mockDate, + from: [{ address: 'sender@example.com' }], + to: [{ address: 'user@gmail.com' }], + cc: [], + subject: 'Gmail Subject', + }, + source: Buffer.from('Message-ID: \r\n\r\nGmail body'), + bodyStructure: {}, + }); + + const result = await provider.readEmail('INBOX', 7); + + expect(result.uid).toBe(7); + expect(result.from).toBe('sender@example.com'); + expect(result.subject).toBe('Gmail Subject'); + expect(result.messageId).toBe('gmail-id'); + expect(mockLock.release).toHaveBeenCalled(); + expect(mockClient.logout).toHaveBeenCalled(); + }); + + it('throws when email not found', async () => { + mockClient.fetchOne.mockResolvedValue(undefined); + + await expect(provider.readEmail('INBOX', 42)).rejects.toThrow('Email with UID 42 not found'); + expect(mockLock.release).toHaveBeenCalled(); + }); + }); + + describe('sendEmail', () => { + it('delegates to sendViaGmailApi with the OAuth credentials', async () => { + vi.mocked(sendViaGmailApi).mockResolvedValue({ + messageId: '', + accepted: ['to@example.com'], + rejected: [], + }); + + const result = await provider.sendEmail({ + to: ['to@example.com'], + subject: 'Hello', + body: 'World', + }); + + expect(sendViaGmailApi).toHaveBeenCalledWith( + expect.objectContaining({ to: ['to@example.com'], subject: 'Hello' }), + 'access-token-123', + 'user@gmail.com', + ); + expect(result.messageId).toBe(''); + }); + }); + + describe('replyToEmail', () => { + it('reads original via IMAP then delegates to replyViaGmailApi', async () => { + const originalMessage = { + uid: 10, + messageId: 'orig-msg-id', + date: new Date(), + from: 'sender@example.com', + to: ['user@gmail.com'], + cc: [], + subject: 'Original', + textBody: 'Original body', + attachments: [], + references: [], + }; + + mockClient.fetchOne.mockResolvedValue({ + uid: 10, + envelope: { + date: originalMessage.date, + from: [{ address: 'sender@example.com' }], + to: [{ address: 'user@gmail.com' }], + cc: [], + subject: 'Original', + }, + source: Buffer.from('Message-ID: \r\n\r\nOriginal body'), + bodyStructure: {}, + }); + + vi.mocked(replyViaGmailApi).mockResolvedValue({ + messageId: '', + accepted: ['sender@example.com'], + rejected: [], + }); + + const result = await provider.replyToEmail({ + folder: 'INBOX', + uid: 10, + body: 'Reply body', + replyAll: false, + }); + + expect(replyViaGmailApi).toHaveBeenCalledWith( + expect.objectContaining({ uid: 10, body: 'Reply body', replyAll: false }), + expect.objectContaining({ uid: 10 }), + 'access-token-123', + 'user@gmail.com', + ); + expect(result.messageId).toBe(''); + }); + }); + + describe('markEmailAsSeen', () => { + it('calls messageFlagsAdd via IMAP', async () => { + mockClient.messageFlagsAdd.mockResolvedValue(undefined); + + await provider.markEmailAsSeen('INBOX', 5); + + expect(mockClient.messageFlagsAdd).toHaveBeenCalledWith(5, ['\\Seen'], { uid: true }); + expect(mockClient.logout).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/email/imap/adapter.test.ts b/tests/unit/email/imap/adapter.test.ts new file mode 100644 index 00000000..4740f249 --- /dev/null +++ b/tests/unit/email/imap/adapter.test.ts @@ -0,0 +1,260 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../src/utils/logging.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +const { mockLock, mockClient, mockTransport } = vi.hoisted(() => { + const mockLock = { release: vi.fn() }; + const mockClient = { + connect: vi.fn(), + logout: vi.fn(), + getMailboxLock: vi.fn().mockResolvedValue(mockLock), + search: vi.fn(), + fetch: vi.fn(), + fetchOne: vi.fn(), + messageFlagsAdd: vi.fn(), + }; + const mockTransport = { + sendMail: vi.fn(), + close: vi.fn(), + }; + return { mockLock, mockClient, mockTransport }; +}); + +vi.mock('imapflow', () => ({ + ImapFlow: vi.fn().mockImplementation(() => mockClient), +})); + +vi.mock('nodemailer', () => ({ + default: { + createTransport: vi.fn().mockReturnValue(mockTransport), + }, +})); + +import { ImapEmailProvider } from '../../../../src/email/imap/adapter.js'; +import type { PasswordEmailCredentials } from '../../../../src/email/types.js'; + +const testCreds: PasswordEmailCredentials = { + authMethod: 'password', + imapHost: 'imap.example.com', + imapPort: 993, + smtpHost: 'smtp.example.com', + smtpPort: 587, + username: 'user@example.com', + password: 'secret', +}; + +describe('ImapEmailProvider', () => { + let provider: ImapEmailProvider; + + beforeEach(() => { + vi.clearAllMocks(); + provider = new ImapEmailProvider(testCreds); + mockClient.connect.mockResolvedValue(undefined); + mockClient.logout.mockResolvedValue(undefined); + mockClient.getMailboxLock.mockResolvedValue(mockLock); + mockLock.release.mockReturnValue(undefined); + }); + + it('has type "imap"', () => { + expect(provider.type).toBe('imap'); + }); + + describe('searchEmails', () => { + it('returns empty array when no messages found', async () => { + mockClient.search.mockResolvedValue([]); + + const result = await provider.searchEmails('INBOX', {}, 10); + + expect(result).toEqual([]); + expect(mockClient.connect).toHaveBeenCalled(); + expect(mockClient.logout).toHaveBeenCalled(); + }); + + it('returns empty array when search returns false', async () => { + mockClient.search.mockResolvedValue(false); + + const result = await provider.searchEmails('INBOX', {}, 10); + expect(result).toEqual([]); + }); + + it('fetches messages and returns summaries', async () => { + mockClient.search.mockResolvedValue([1, 2]); + + const mockMessages = [ + { + uid: 2, + envelope: { + date: new Date('2024-01-15'), + from: [{ address: 'sender@example.com' }], + to: [{ address: 'recipient@example.com' }], + subject: 'Test subject', + }, + source: Buffer.from('header snippet'), + }, + ]; + + async function* asyncGen() { + for (const msg of mockMessages) yield msg; + } + mockClient.fetch.mockReturnValue(asyncGen()); + + const result = await provider.searchEmails('INBOX', {}, 10); + + expect(result).toHaveLength(1); + expect(result[0].uid).toBe(2); + expect(result[0].subject).toBe('Test subject'); + }); + }); + + describe('readEmail', () => { + it('returns EmailMessage fields on success', async () => { + const mockDate = new Date('2024-03-01'); + mockClient.fetchOne.mockResolvedValue({ + uid: 5, + envelope: { + date: mockDate, + from: [{ address: 'sender@example.com' }], + to: [{ address: 'recipient@example.com' }], + cc: [], + subject: 'Test Subject', + }, + source: Buffer.from('Message-ID: \r\n\r\nHello world'), + bodyStructure: {}, + }); + + const result = await provider.readEmail('INBOX', 5); + + expect(result.uid).toBe(5); + expect(result.from).toBe('sender@example.com'); + expect(result.subject).toBe('Test Subject'); + expect(result.messageId).toBe('test-id'); + expect(mockLock.release).toHaveBeenCalled(); + expect(mockClient.logout).toHaveBeenCalled(); + }); + + it('throws when email not found', async () => { + mockClient.fetchOne.mockResolvedValue(undefined); + + await expect(provider.readEmail('INBOX', 99)).rejects.toThrow('Email with UID 99 not found'); + expect(mockLock.release).toHaveBeenCalled(); + }); + }); + + describe('sendEmail', () => { + it('sends via nodemailer SMTP and returns result', async () => { + mockTransport.sendMail.mockResolvedValue({ + messageId: '', + accepted: ['to@example.com'], + rejected: [], + }); + mockTransport.close.mockResolvedValue(undefined); + + const result = await provider.sendEmail({ + to: ['to@example.com'], + subject: 'Hello', + body: 'World', + }); + + expect(result.messageId).toBe(''); + expect(result.accepted).toEqual(['to@example.com']); + expect(mockTransport.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + from: 'user@example.com', + to: ['to@example.com'], + subject: 'Hello', + }), + ); + }); + + it('closes transport even on error', async () => { + mockTransport.sendMail.mockRejectedValue(new Error('SMTP failed')); + mockTransport.close.mockResolvedValue(undefined); + + await expect( + provider.sendEmail({ to: ['to@example.com'], subject: 'Hello', body: 'World' }), + ).rejects.toThrow('SMTP failed'); + + expect(mockTransport.close).toHaveBeenCalled(); + }); + }); + + describe('replyToEmail', () => { + const originalMessageFixture = { + uid: 5, + envelope: { + date: new Date('2024-03-01'), + from: [{ address: 'sender@example.com' }], + to: [{ address: 'user@example.com' }, { address: 'other@example.com' }], + cc: [], + subject: 'Original Subject', + }, + source: Buffer.from('Message-ID: \r\n\r\nOriginal body'), + bodyStructure: {}, + }; + + it('sends with threading headers and filters self from reply-all recipients', async () => { + mockClient.fetchOne.mockResolvedValue(originalMessageFixture); + mockTransport.sendMail.mockResolvedValue({ + messageId: '', + accepted: ['sender@example.com'], + rejected: [], + }); + mockTransport.close.mockResolvedValue(undefined); + + const result = await provider.replyToEmail({ + folder: 'INBOX', + uid: 5, + body: 'My reply', + replyAll: true, + }); + + expect(result.messageId).toBe(''); + expect(mockTransport.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + inReplyTo: '', + references: '', + // reply-all: self (user@example.com) is filtered out; other@example.com kept + to: expect.arrayContaining(['sender@example.com', 'other@example.com']), + }), + ); + expect(mockLock.release).toHaveBeenCalled(); + }); + + it('closes transport on error', async () => { + mockClient.fetchOne.mockResolvedValue(originalMessageFixture); + mockTransport.sendMail.mockRejectedValue(new Error('SMTP error')); + mockTransport.close.mockResolvedValue(undefined); + + await expect( + provider.replyToEmail({ folder: 'INBOX', uid: 5, body: 'My reply', replyAll: false }), + ).rejects.toThrow('SMTP error'); + + expect(mockTransport.close).toHaveBeenCalled(); + }); + }); + + describe('markEmailAsSeen', () => { + it('calls messageFlagsAdd with Seen flag', async () => { + mockClient.messageFlagsAdd.mockResolvedValue(undefined); + + await provider.markEmailAsSeen('INBOX', 42); + + expect(mockClient.messageFlagsAdd).toHaveBeenCalledWith(42, ['\\Seen'], { uid: true }); + expect(mockClient.logout).toHaveBeenCalled(); + }); + + it('releases lock and re-throws on error', async () => { + mockClient.messageFlagsAdd.mockRejectedValue(new Error('flag error')); + + await expect(provider.markEmailAsSeen('INBOX', 42)).rejects.toThrow('flag error'); + expect(mockLock.release).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/email/integration.test.ts b/tests/unit/email/integration.test.ts index e07224ad..69b7a07b 100644 --- a/tests/unit/email/integration.test.ts +++ b/tests/unit/email/integration.test.ts @@ -1,14 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -vi.mock('../../../src/config/provider.js', () => ({ - getIntegrationCredentialOrNull: vi.fn(), - getOrgCredential: vi.fn(), -})); - vi.mock('../../../src/db/repositories/credentialsRepository.js', () => ({ getIntegrationProvider: vi.fn(), })); +vi.mock('../../../src/email/registry.js', () => ({ + emailRegistry: { + getOrNull: vi.fn(), + }, +})); + vi.mock('../../../src/utils/logging.js', () => ({ logger: { debug: vi.fn(), @@ -18,13 +19,9 @@ vi.mock('../../../src/utils/logging.js', () => ({ }, })); -import { getIntegrationCredentialOrNull } from '../../../src/config/provider.js'; import { getIntegrationProvider } from '../../../src/db/repositories/credentialsRepository.js'; -import { - hasEmailIntegration, - resolveEmailCredentials, - withEmailIntegration, -} from '../../../src/email/integration.js'; +import { hasEmailIntegration, withEmailIntegration } from '../../../src/email/integration.js'; +import { emailRegistry } from '../../../src/email/registry.js'; import { logger } from '../../../src/utils/logging.js'; describe('email integration', () => { @@ -32,157 +29,110 @@ describe('email integration', () => { vi.clearAllMocks(); }); - describe('resolveEmailCredentials', () => { - it('returns credentials when all fields are present', async () => { - vi.mocked(getIntegrationProvider).mockResolvedValue('imap'); - vi.mocked(getIntegrationCredentialOrNull).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; - }, - ); + describe('withEmailIntegration', () => { + it('runs fn directly when no provider is configured', async () => { + vi.mocked(getIntegrationProvider).mockResolvedValue(null); + + const fn = vi.fn().mockResolvedValue('result'); + const result = await withEmailIntegration('project-1', fn); - const result = await resolveEmailCredentials('project-1'); - - expect(result).toEqual({ - authMethod: 'password', - imapHost: 'imap.example.com', - imapPort: 993, - smtpHost: 'smtp.example.com', - smtpPort: 587, - username: 'user@example.com', - password: 'secret', - }); + expect(fn).toHaveBeenCalled(); + expect(result).toBe('result'); }); - it('returns null when a credential is missing', async () => { - vi.mocked(getIntegrationProvider).mockResolvedValue('imap'); - vi.mocked(getIntegrationCredentialOrNull).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; - }, - ); + it('runs fn directly when provider is unknown in registry', async () => { + vi.mocked(getIntegrationProvider).mockResolvedValue('unknown-provider'); + vi.mocked(emailRegistry.getOrNull).mockReturnValue(null); + + const fn = vi.fn().mockResolvedValue('result'); + const result = await withEmailIntegration('project-1', fn); - const result = await resolveEmailCredentials('project-1'); - expect(result).toBeNull(); + expect(fn).toHaveBeenCalled(); + expect(result).toBe('result'); }); - it('returns null when port is not a valid number', async () => { + it('delegates to the registered integration when provider is known', async () => { + const mockIntegration = { + type: 'imap', + withCredentials: vi + .fn() + .mockImplementation((_projectId: string, fn: () => Promise) => fn()), + hasCredentials: vi.fn(), + }; + vi.mocked(getIntegrationProvider).mockResolvedValue('imap'); - vi.mocked(getIntegrationCredentialOrNull).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; - }, - ); + vi.mocked(emailRegistry.getOrNull).mockReturnValue(mockIntegration); - const result = await resolveEmailCredentials('project-1'); - expect(result).toBeNull(); + const fn = vi.fn().mockResolvedValue('scoped-result'); + const result = await withEmailIntegration('project-1', fn); + + expect(mockIntegration.withCredentials).toHaveBeenCalledWith('project-1', fn); + expect(result).toBe('scoped-result'); }); - it('logs warning and returns null on error', async () => { + it('falls back to fn() and warns when getIntegrationProvider throws', async () => { vi.mocked(getIntegrationProvider).mockRejectedValue(new Error('DB error')); - const result = await resolveEmailCredentials('project-1'); + const fn = vi.fn().mockResolvedValue('fallback-result'); + const result = await withEmailIntegration('project-1', fn); - expect(result).toBeNull(); + expect(fn).toHaveBeenCalled(); + expect(result).toBe('fallback-result'); expect(logger.warn).toHaveBeenCalledWith( - 'Failed to resolve email credentials', - expect.objectContaining({ - projectId: 'project-1', - error: 'DB error', - }), + 'Failed to resolve email integration, running without email credentials', + expect.objectContaining({ projectId: 'project-1', error: 'DB error' }), ); }); + }); - it('returns null when no email integration is configured', async () => { + describe('hasEmailIntegration', () => { + it('returns false when no provider is configured', async () => { vi.mocked(getIntegrationProvider).mockResolvedValue(null); - const result = await resolveEmailCredentials('project-1'); - expect(result).toBeNull(); + const result = await hasEmailIntegration('project-1'); + expect(result).toBe(false); }); - }); - - describe('withEmailIntegration', () => { - it('runs function with credentials when available', async () => { - vi.mocked(getIntegrationProvider).mockResolvedValue('imap'); - vi.mocked(getIntegrationCredentialOrNull).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); + it('returns false when provider is unknown in registry', async () => { + vi.mocked(getIntegrationProvider).mockResolvedValue('unknown-provider'); + vi.mocked(emailRegistry.getOrNull).mockReturnValue(null); - expect(fn).toHaveBeenCalled(); - expect(result).toBe('result'); + const result = await hasEmailIntegration('project-1'); + expect(result).toBe(false); }); - it('runs function without credentials when not configured', async () => { - vi.mocked(getIntegrationProvider).mockResolvedValue(null); + it('delegates to registered integration hasCredentials', async () => { + const mockIntegration = { + type: 'imap', + withCredentials: vi.fn(), + hasCredentials: vi.fn().mockResolvedValue(true), + }; - const fn = vi.fn().mockResolvedValue('result'); - const result = await withEmailIntegration('project-1', fn); + vi.mocked(getIntegrationProvider).mockResolvedValue('imap'); + vi.mocked(emailRegistry.getOrNull).mockReturnValue(mockIntegration); - expect(fn).toHaveBeenCalled(); - expect(result).toBe('result'); + const result = await hasEmailIntegration('project-1'); + + expect(mockIntegration.hasCredentials).toHaveBeenCalledWith('project-1'); + expect(result).toBe(true); }); - }); - describe('hasEmailIntegration', () => { - it('returns true when credentials are configured', async () => { + it('returns false when registered integration has no credentials', async () => { + const mockIntegration = { + type: 'imap', + withCredentials: vi.fn(), + hasCredentials: vi.fn().mockResolvedValue(false), + }; + vi.mocked(getIntegrationProvider).mockResolvedValue('imap'); - vi.mocked(getIntegrationCredentialOrNull).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; - }, - ); + vi.mocked(emailRegistry.getOrNull).mockReturnValue(mockIntegration); const result = await hasEmailIntegration('project-1'); - expect(result).toBe(true); + expect(result).toBe(false); }); - it('returns false when credentials are not configured', async () => { - vi.mocked(getIntegrationProvider).mockResolvedValue(null); + it('returns false when getIntegrationProvider throws', async () => { + vi.mocked(getIntegrationProvider).mockRejectedValue(new Error('DB error')); 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 f4154c8f..0fae38c2 100644 --- a/tests/unit/gadgets/email/core.test.ts +++ b/tests/unit/gadgets/email/core.test.ts @@ -1,11 +1,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -vi.mock('../../../../src/email/client.js', () => ({ +const mockProvider = { + type: 'imap', sendEmail: vi.fn(), searchEmails: vi.fn(), readEmail: vi.fn(), replyToEmail: vi.fn(), markEmailAsSeen: vi.fn(), +}; + +vi.mock('../../../../src/email/context.js', () => ({ + getEmailProvider: vi.fn(() => mockProvider), })); vi.mock('../../../../src/utils/logging.js', () => ({ @@ -17,13 +22,6 @@ 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'; @@ -38,7 +36,7 @@ describe('email gadget core functions', () => { describe('sendEmail', () => { it('returns success message when email is sent', async () => { - vi.mocked(sendEmailClient).mockResolvedValue({ + mockProvider.sendEmail.mockResolvedValue({ messageId: '', accepted: ['user@example.com'], rejected: [], @@ -56,7 +54,7 @@ describe('email gadget core functions', () => { }); it('includes rejected recipients in output', async () => { - vi.mocked(sendEmailClient).mockResolvedValue({ + mockProvider.sendEmail.mockResolvedValue({ messageId: '', accepted: ['user@example.com'], rejected: ['bad@example.com'], @@ -72,7 +70,7 @@ describe('email gadget core functions', () => { }); it('does not show empty rejected list', async () => { - vi.mocked(sendEmailClient).mockResolvedValue({ + mockProvider.sendEmail.mockResolvedValue({ messageId: '', accepted: ['user@example.com'], rejected: [], @@ -88,7 +86,7 @@ describe('email gadget core functions', () => { }); it('returns error message and logs on failure', async () => { - vi.mocked(sendEmailClient).mockRejectedValue(new Error('SMTP connection failed')); + mockProvider.sendEmail.mockRejectedValue(new Error('SMTP connection failed')); const result = await sendEmail({ to: ['user@example.com'], @@ -106,7 +104,7 @@ describe('email gadget core functions', () => { describe('searchEmails', () => { it('returns formatted results when emails found', async () => { - vi.mocked(searchEmailsClient).mockResolvedValue([ + mockProvider.searchEmails.mockResolvedValue([ { uid: 123, date: new Date('2024-01-15'), @@ -135,7 +133,7 @@ describe('email gadget core functions', () => { }); it('returns message when no emails found', async () => { - vi.mocked(searchEmailsClient).mockResolvedValue([]); + mockProvider.searchEmails.mockResolvedValue([]); const result = await searchEmails('INBOX', { from: 'nobody@example.com' }, 10); @@ -143,7 +141,7 @@ describe('email gadget core functions', () => { }); it('returns error message and logs on failure', async () => { - vi.mocked(searchEmailsClient).mockRejectedValue(new Error('IMAP timeout')); + mockProvider.searchEmails.mockRejectedValue(new Error('IMAP timeout')); const result = await searchEmails('INBOX', {}, 10); @@ -157,7 +155,7 @@ describe('email gadget core functions', () => { describe('readEmail', () => { it('returns formatted email content', async () => { - vi.mocked(readEmailClient).mockResolvedValue({ + mockProvider.readEmail.mockResolvedValue({ uid: 123, messageId: '', date: new Date('2024-01-15T10:30:00Z'), @@ -179,7 +177,7 @@ describe('email gadget core functions', () => { }); it('shows CC when present', async () => { - vi.mocked(readEmailClient).mockResolvedValue({ + mockProvider.readEmail.mockResolvedValue({ uid: 123, messageId: '', date: new Date('2024-01-15'), @@ -198,7 +196,7 @@ describe('email gadget core functions', () => { }); it('shows HTML body when text body is empty', async () => { - vi.mocked(readEmailClient).mockResolvedValue({ + mockProvider.readEmail.mockResolvedValue({ uid: 123, messageId: '', date: new Date('2024-01-15'), @@ -219,7 +217,7 @@ describe('email gadget core functions', () => { }); it('shows attachments when present', async () => { - vi.mocked(readEmailClient).mockResolvedValue({ + mockProvider.readEmail.mockResolvedValue({ uid: 123, messageId: '', date: new Date('2024-01-15'), @@ -239,7 +237,7 @@ describe('email gadget core functions', () => { }); it('returns error message and logs on failure', async () => { - vi.mocked(readEmailClient).mockRejectedValue(new Error('Email not found')); + mockProvider.readEmail.mockRejectedValue(new Error('Email not found')); const result = await readEmail('INBOX', 999); @@ -253,7 +251,7 @@ describe('email gadget core functions', () => { describe('replyToEmail', () => { it('returns success message when reply is sent', async () => { - vi.mocked(replyToEmailClient).mockResolvedValue({ + mockProvider.replyToEmail.mockResolvedValue({ messageId: '', accepted: ['sender@example.com'], rejected: [], @@ -265,7 +263,7 @@ describe('email gadget core functions', () => { }); it('includes rejected recipients in output', async () => { - vi.mocked(replyToEmailClient).mockResolvedValue({ + mockProvider.replyToEmail.mockResolvedValue({ messageId: '', accepted: ['sender@example.com'], rejected: ['bad@example.com'], @@ -277,7 +275,7 @@ describe('email gadget core functions', () => { }); it('returns error message and logs on failure', async () => { - vi.mocked(replyToEmailClient).mockRejectedValue(new Error('Connection refused')); + mockProvider.replyToEmail.mockRejectedValue(new Error('Connection refused')); const result = await replyToEmail('INBOX', 123, 'Reply body', false); @@ -295,16 +293,16 @@ describe('email gadget core functions', () => { describe('markEmailAsSeen', () => { it('returns success message when email is marked as seen', async () => { - vi.mocked(markEmailAsSeenClient).mockResolvedValue(undefined); + mockProvider.markEmailAsSeen.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); + expect(mockProvider.markEmailAsSeen).toHaveBeenCalledWith('INBOX', 456); }); it('returns error message and logs on failure', async () => { - vi.mocked(markEmailAsSeenClient).mockRejectedValue(new Error('IMAP flag error')); + mockProvider.markEmailAsSeen.mockRejectedValue(new Error('IMAP flag error')); const result = await markEmailAsSeen('INBOX', 789); diff --git a/tools/test-email-integration.ts b/tools/test-email-integration.ts index 62fea1b8..aadb5aae 100644 --- a/tools/test-email-integration.ts +++ b/tools/test-email-integration.ts @@ -19,15 +19,8 @@ */ 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'; +import { ImapEmailProvider } from '../src/email/imap/adapter.js'; +import type { EmailSummary, PasswordEmailCredentials } from '../src/email/types.js'; const { values } = parseArgs({ options: { @@ -65,7 +58,7 @@ if (!values.username || !values.password) { process.exit(1); } -const credentials: EmailCredentials = { +const credentials: PasswordEmailCredentials = { authMethod: 'password', imapHost: 'imap.gmail.com', imapPort: 993, @@ -96,8 +89,8 @@ async function runTest(name: string, fn: () => Promise): Promise { console.log(); } -async function testSearchRecentEmails(): Promise { - const results = await searchEmails('INBOX', { since: getRecentDateString() }, 5); +async function testSearchRecentEmails(provider: ImapEmailProvider): Promise { + const results = await provider.searchEmails('INBOX', { since: getRecentDateString() }, 5); for (const email of results) { console.log( `[UID:${email.uid}] ${email.date.toISOString()} - ${email.from} - ${email.subject}`, @@ -106,12 +99,12 @@ async function testSearchRecentEmails(): Promise { return results; } -async function testReadFirstEmail(): Promise { - const results = await searchEmails('INBOX', { since: getRecentDateString() }, 1); +async function testReadFirstEmail(provider: ImapEmailProvider): Promise { + const results = await provider.searchEmails('INBOX', { since: getRecentDateString() }, 1); const firstUid = extractUid(results); if (firstUid) { console.log(`Reading email UID: ${firstUid}`); - const email = await readEmail('INBOX', firstUid); + const email = await provider.readEmail('INBOX', firstUid); console.log(email); } else { console.log('No emails found to read'); @@ -119,8 +112,8 @@ async function testReadFirstEmail(): Promise { return firstUid; } -async function testSendEmail(toAddress: string): Promise { - const result = await sendEmail({ +async function testSendEmail(provider: ImapEmailProvider, toAddress: string): Promise { + const result = await provider.sendEmail({ to: [toAddress], subject: `CASCADE Email Test - ${new Date().toISOString()}`, body: `This is a test email from CASCADE email integration. @@ -132,8 +125,8 @@ 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); +async function testSearchSentEmail(provider: ImapEmailProvider): Promise { + const results = await provider.searchEmails('INBOX', { subject: 'CASCADE Email Test' }, 5); for (const email of results) { console.log( `[UID:${email.uid}] ${email.date.toISOString()} - ${email.from} - ${email.subject}`, @@ -142,8 +135,8 @@ async function testSearchSentEmail(): Promise { return extractUid(results); } -async function testReplyToEmail(uid: number): Promise { - const result = await replyToEmail({ +async function testReplyToEmail(provider: ImapEmailProvider, uid: number): Promise { + const result = await provider.replyToEmail({ folder: 'INBOX', uid, body: `This is an automated reply from CASCADE. @@ -156,11 +149,13 @@ Original email UID: ${uid}`, console.log(result); } -async function runAllTests(): Promise { - await runTest('TEST 1: Search recent emails (INBOX, last 7 days)', testSearchRecentEmails); +async function runAllTests(provider: ImapEmailProvider): Promise { + await runTest('TEST 1: Search recent emails (INBOX, last 7 days)', () => + testSearchRecentEmails(provider), + ); await runTest('TEST 2: Read first email from search', async () => { - await testReadFirstEmail(); + await testReadFirstEmail(provider); }); if (values['skip-send']) { @@ -170,7 +165,7 @@ async function runAllTests(): Promise { } await runTest('TEST 3: Send test email to yourself', async () => { - await testSendEmail(credentials.username); + await testSendEmail(provider, credentials.username); }); console.log('Waiting 5 seconds for email to arrive...'); @@ -179,12 +174,12 @@ async function runAllTests(): Promise { let sentUid: number | null = null; await runTest('TEST 4: Search for sent email', async () => { - sentUid = await testSearchSentEmail(); + sentUid = await testSearchSentEmail(provider); }); await runTest('TEST 5: Reply to the test email', async () => { if (sentUid) { - await testReplyToEmail(sentUid); + await testReplyToEmail(provider, sentUid); } else { console.log('SKIPPED: No email UID available to reply to'); } @@ -201,7 +196,8 @@ async function main(): Promise { console.log('='.repeat(60)); console.log(); - await withEmailCredentials(credentials, runAllTests); + const provider = new ImapEmailProvider(credentials); + await runAllTests(provider); console.log('='.repeat(60)); console.log('Test complete!');