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
5 changes: 4 additions & 1 deletion src/channels/email/smtp-imap/SmtpImapChannelHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export class SmtpImapChannelHost {
res.status(500).json({ error: 'Channel provider config is invalid' });
return;
}
const { fromAddress, smtp, threadingStrategy } = configResult.data;
const { fromAddress, smtp, threadingStrategy, oauth2 } = configResult.data;

let resolvedStageId = body.stageId ?? queryStageId;
if (!resolvedStageId) {
Expand Down Expand Up @@ -165,6 +165,7 @@ export class SmtpImapChannelHost {
smtp.secure,
smtp.auth.user,
smtp.auth.pass,
oauth2?.accessToken,
);

try {
Expand Down Expand Up @@ -241,6 +242,7 @@ export class SmtpImapChannelHost {
references: string | string[] | undefined,
stageId: string | undefined,
agentId: string | undefined,
oauth2AccessToken: string | undefined,
): Promise<void> {
const replyConversationId = extractConversationIdFromMessageId(inReplyTo) ?? extractConversationIdFromReferences(references);
logger.info({ projectId, from: senderEmail, inReplyTo, references, replyConversationId }, 'SMTP/IMAP: inbound email threading headers');
Expand Down Expand Up @@ -275,6 +277,7 @@ export class SmtpImapChannelHost {
smtpSecure,
smtpAuthUser,
smtpAuthPass,
oauth2AccessToken,
);

try {
Expand Down
47 changes: 38 additions & 9 deletions src/channels/email/smtp-imap/SmtpImapConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ import { logger } from '../../../utils/logger';
export class SmtpImapConnection extends EmailConnectionBase {
readonly connectionType = 'smtp_imap' as const;

private transporter: nodemailer.Transporter;
private transporter: nodemailer.Transporter | null = null;
private readonly smtpAuthUser: string;
private readonly smtpHost: string;
private readonly smtpPort: number;
private readonly smtpSecure: boolean;
private conversationId: string | undefined;
private inboundMessageId: string | undefined;
private referencesChain: string[] = [];
Expand All @@ -26,21 +29,43 @@ export class SmtpImapConnection extends EmailConnectionBase {
smtpSecure: boolean,
smtpAuthUser: string,
smtpAuthPass: string,
private oauth2AccessToken: string | undefined,
) {
super(fromAddress, threadingStrategy, sessionManager, 'smtp_imap');
this.smtpAuthUser = smtpAuthUser;
this.smtpHost = smtpHost;
this.smtpPort = smtpPort;
this.smtpSecure = smtpSecure;
this.oauth2AccessToken = oauth2AccessToken || undefined;
this.createTransporter(smtpAuthPass);
}

setOAuth2AccessToken(token: string): void {
this.oauth2AccessToken = token;
this.createTransporter();
}

private createTransporter(smtpAuthPass?: string): void {
this.transporter = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
auth: {
user: smtpAuthUser,
pass: smtpAuthPass,
},
});
host: this.smtpHost,
port: this.smtpPort,
secure: this.smtpSecure,
auth: this.oauth2AccessToken
? {
user: this.smtpAuthUser,
xoauth2: Buffer.from(`user=${this.smtpAuthUser}\x01auth=Bearer ${this.oauth2AccessToken}\x01\x01`, 'utf-8').toString('base64'),
}
: {
user: this.smtpAuthUser,
pass: smtpAuthPass,
},
} as nodemailer.TransportOptions);
}

async verifyConnection(): Promise<void> {
if (!this.transporter) {
throw new Error('SMTP transporter not initialized');
}
try {
await this.transporter.verify();
logger.info({ to: this.toAddress }, 'SMTP/IMAP: transporter verified successfully');
Expand Down Expand Up @@ -127,6 +152,10 @@ export class SmtpImapConnection extends EmailConnectionBase {
(mailOptions.headers as Record<string, string>)['References'] = headers.references;
}

if (!this.transporter) {
logger.error({ to }, 'SMTP/IMAP: transporter not initialized');
return;
}
try {
const info = await this.transporter.sendMail(mailOptions);
logger.info({ to, messageId, sessionId: this.session?.id, messageIdRemote: info.messageId }, 'SMTP/IMAP email sent');
Expand Down
10 changes: 10 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,16 @@ export class ConflictError extends Error {
}
}

/**
* Error thrown when an OAuth2 token refresh fails for an SMTP/IMAP provider
*/
export class OAuthTokenRefreshError extends Error {
constructor(message: string) {
super(message);
this.name = 'OAuthTokenRefreshError';
}
}

/** A single field-level validation error detail, compatible with Zod issue format */
export type ValidationErrorDetail = {
code: string;
Expand Down
44 changes: 44 additions & 0 deletions src/http/contracts/smtp-imap-oauth2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { z } from 'zod';
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';

extendZodWithOpenApi(z);

export const oauth2AuthorizeBodySchema = z.strictObject({
providerId: z.string().min(1).describe('ID of the SMTP/IMAP channel provider to configure OAuth2 for'),
tokenUrl: z.string().url().describe('OAuth2 token endpoint URL (e.g. https://oauth2.googleapis.com/token for Gmail)'),
authorizationUrl: z.string().url().describe('OAuth2 authorization endpoint URL (e.g. https://accounts.google.com/o/oauth2/v2/auth for Gmail)'),
clientId: z.string().min(1).describe('OAuth2 client ID'),
scope: z.string().min(1).describe('OAuth2 scope string (e.g. https://www.googleapis.com/auth/gmail.modify for Gmail)'),
redirectUrl: z.string().url().describe('Redirect URI registered with the OAuth2 provider (must match the callback endpoint)'),
});

export const oauth2AuthorizeResponseSchema = z.strictObject({
authorizationUrl: z.string().url().describe('Full authorization URL to redirect the user to'),
state: z.string().describe('Random state parameter for CSRF protection'),
});

export const oauth2CallbackQuerySchema = z.strictObject({
code: z.string().min(1).describe('Authorization code from the OAuth2 provider'),
state: z.string().min(1).describe('State parameter that was returned from the authorization URL'),
});

export const oauth2CallbackResponseSchema = z.strictObject({
success: z.boolean().describe('Whether the OAuth2 callback was processed successfully'),
message: z.string().describe('Human-readable result message'),
});

export const oauth2RefreshBodySchema = z.strictObject({
providerId: z.string().min(1).describe('ID of the SMTP/IMAP channel provider to refresh tokens for'),
});

export const oauth2RefreshResponseSchema = z.strictObject({
success: z.boolean().describe('Whether the token refresh was successful'),
accessTokenExpiry: z.number().int().optional().describe('Unix timestamp in milliseconds when the access token expires'),
});

export type OAuth2AuthorizeBody = z.infer<typeof oauth2AuthorizeBodySchema>;
export type OAuth2AuthorizeResponse = z.infer<typeof oauth2AuthorizeResponseSchema>;
export type OAuth2CallbackQuery = z.infer<typeof oauth2CallbackQuerySchema>;
export type OAuth2CallbackResponse = z.infer<typeof oauth2CallbackResponseSchema>;
export type OAuth2RefreshBody = z.infer<typeof oauth2RefreshBodySchema>;
export type OAuth2RefreshResponse = z.infer<typeof oauth2RefreshResponseSchema>;
Loading
Loading