Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
528 changes: 0 additions & 528 deletions src/email/client.ts

This file was deleted.

29 changes: 29 additions & 0 deletions src/email/context.ts
Original file line number Diff line number Diff line change
@@ -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<EmailProvider>();

export function withEmailProvider<T>(provider: EmailProvider, fn: () => Promise<T>): Promise<T> {
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;
}
171 changes: 171 additions & 0 deletions src/email/gmail/adapter.ts
Original file line number Diff line number Diff line change
@@ -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<EmailSummary[]> {
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<EmailMessage> {
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<SendEmailResult> {
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<SendEmailResult> {
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<void> {
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();
}
}
}
82 changes: 82 additions & 0 deletions src/email/gmail/integration.ts
Original file line number Diff line number Diff line change
@@ -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<T>(projectId: string, fn: () => Promise<T>): Promise<T> {
const creds = await this.resolveCredentials(projectId);
if (!creds) {
return fn();
}
return withEmailProvider(new GmailEmailProvider(creds), fn);
}

async hasCredentials(projectId: string): Promise<boolean> {
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;
}
}
}
Loading