import { Router, Request, Response, NextFunction } from 'express'; import { z } from 'zod'; import { InboxStatus, MailboxAuthType, MailboxProvider, MailboxRole } from '../lib/prisma'; import { prisma } from '../lib/prisma'; import { encrypt } from '../services/crypto.service'; import { testConnection } from '../services/email.service'; import { isMicrosoftConsumerEmail } from '../services/microsoft-oauth.service'; import { stripPassword } from '../lib/inboxGuard'; import { AppError } from '../middleware/errorHandler'; import { applyProviderPreset, PROVIDER_PRESETS } from '../models/provider-presets'; import { checkMailboxDns } from '../lib/dns-check'; import { resolveWorkspaceId } from '../services/workspace.service'; const router = Router(); const createInboxSchema = z.object({ email: z.string().email(), smtpHost: z.string().min(1).optional(), smtpPort: z.coerce.number().int().positive().optional(), imapHost: z.string().min(1).optional(), imapPort: z.coerce.number().int().positive().optional(), username: z.string().min(1), password: z.string().optional(), authType: z.nativeEnum(MailboxAuthType).optional(), oauthRefreshToken: z.string().optional(), workspaceId: z.string().optional(), role: z.nativeEnum(MailboxRole).optional(), provider: z.nativeEnum(MailboxProvider).default(MailboxProvider.custom), dailyLimit: z.coerce.number().int().positive().optional(), currentRamp: z.coerce.number().int().positive().optional(), industry: z.string().min(1).optional(), senderName: z.string().optional(), companyName: z.string().optional(), status: z.nativeEnum(InboxStatus).optional(), }); const updateInboxSchema = z.object({ email: z.string().email().optional(), smtpHost: z.string().min(1).optional(), smtpPort: z.coerce.number().int().positive().optional(), imapHost: z.string().min(1).optional(), imapPort: z.coerce.number().int().positive().optional(), username: z.string().min(1).optional(), password: z.string().optional(), authType: z.nativeEnum(MailboxAuthType).optional(), oauthRefreshToken: z.string().optional(), role: z.nativeEnum(MailboxRole).optional(), provider: z.nativeEnum(MailboxProvider).optional(), dailyLimit: z.coerce.number().int().positive().optional(), currentRamp: z.coerce.number().int().positive().optional(), industry: z.string().min(1).optional(), senderName: z.string().optional(), companyName: z.string().optional(), status: z.nativeEnum(InboxStatus).optional(), }); const listQuerySchema = z.object({ role: z.nativeEnum(MailboxRole).optional(), workspaceId: z.string().optional(), }); router.get('/provider-presets', (_req: Request, res: Response) => { res.json(PROVIDER_PRESETS); }); function resolveAuthType( data: z.infer, provider: MailboxProvider, ): MailboxAuthType { if (data.authType) return data.authType; if (provider === MailboxProvider.outlook && data.oauthRefreshToken) { return MailboxAuthType.microsoft_oauth; } return MailboxAuthType.password; } async function validateRoleChange(inboxId: string, newRole: MailboxRole): Promise { const inbox = await prisma.inbox.findUnique({ where: { id: inboxId } }); if (!inbox) throw new AppError(404, 'Inbox not found'); if (newRole === inbox.role) return; if (newRole === MailboxRole.primary) { throw new AppError(400, 'Set a mailbox as primary by creating or editing a campaign'); } if (inbox.role === MailboxRole.primary) { const activeCampaign = await prisma.warmupCampaign.findFirst({ where: { primaryMailboxId: inboxId, status: { in: ['active', 'maintenance', 'paused'] }, }, }); if (activeCampaign) { throw new AppError(400, 'Cannot change role while this mailbox is a campaign primary'); } } if (newRole === MailboxRole.unassigned && inbox.role === MailboxRole.satellite) { const assignments = await prisma.campaignSatellite.count({ where: { satelliteMailboxId: inboxId }, }); if (assignments > 0) { throw new AppError( 400, 'Remove this mailbox from all campaigns before setting role to unassigned', ); } } } router.post('/', async (req: Request, res: Response, next: NextFunction) => { try { const data = createInboxSchema.parse(req.body); const workspaceId = await resolveWorkspaceId(data.workspaceId); const provider = data.provider ?? MailboxProvider.custom; const authType = resolveAuthType(data, provider); if (authType === MailboxAuthType.password && !data.password) { throw new AppError(400, 'Password is required for password authentication'); } if (authType === MailboxAuthType.microsoft_oauth && !data.oauthRefreshToken) { throw new AppError( 400, 'Microsoft OAuth is required for @live.com/@outlook.com accounts. Use Connect Microsoft.', ); } const presetFields = applyProviderPreset(provider, { smtpHost: data.smtpHost, smtpPort: data.smtpPort, imapHost: data.imapHost, imapPort: data.imapPort, }); if (!presetFields.smtpHost || !presetFields.imapHost || !presetFields.smtpPort || !presetFields.imapPort) { throw new AppError(400, 'smtpHost, smtpPort, imapHost, and imapPort are required for custom provider'); } if ( authType === MailboxAuthType.password && provider === MailboxProvider.outlook && isMicrosoftConsumerEmail(data.email) ) { throw new AppError( 400, 'Microsoft consumer accounts (@live.com, @outlook.com) require OAuth. Use Connect Microsoft instead of an app password.', ); } const candidate = { id: 'test', workspaceId, email: data.email, smtpHost: presetFields.smtpHost, smtpPort: presetFields.smtpPort, imapHost: presetFields.imapHost, imapPort: presetFields.imapPort, username: data.username, password: encrypt(data.password ?? 'oauth-placeholder'), authType, oauthRefreshToken: data.oauthRefreshToken ? encrypt(data.oauthRefreshToken) : null, role: data.role ?? MailboxRole.unassigned, provider, status: InboxStatus.active, dailyLimit: data.dailyLimit ?? 40, currentRamp: data.currentRamp ?? 5, industry: data.industry ?? 'SaaS', senderName: data.senderName ?? null, companyName: data.companyName ?? null, lastError: null, errorAt: null, consecutiveFailures: 0, spamFolderPath: null, lastSpamRescuedAt: null, createdAt: new Date(), updatedAt: new Date(), }; const connection = await testConnection(candidate); if (!connection.smtp.ok || !connection.imap.ok) { const errors = [ !connection.smtp.ok ? `SMTP: ${connection.smtp.error}` : null, !connection.imap.ok ? `IMAP: ${connection.imap.error}` : null, ].filter(Boolean); throw new AppError(400, `Connection test failed. ${errors.join(' ')}`); } const inbox = await prisma.inbox.create({ data: { workspaceId, email: data.email, smtpHost: presetFields.smtpHost, smtpPort: presetFields.smtpPort, imapHost: presetFields.imapHost, imapPort: presetFields.imapPort, username: data.username, password: encrypt(data.password ?? 'oauth-placeholder'), authType, oauthRefreshToken: data.oauthRefreshToken ? encrypt(data.oauthRefreshToken) : null, role: data.role ?? MailboxRole.unassigned, provider, dailyLimit: data.dailyLimit ?? 40, currentRamp: data.currentRamp ?? 5, industry: data.industry ?? 'SaaS', senderName: data.senderName, companyName: data.companyName, status: InboxStatus.active, }, }); res.status(201).json(stripPassword(inbox)); } catch (err) { next(err); } }); router.get('/', async (req: Request, res: Response, next: NextFunction) => { try { const { role, workspaceId: wsId } = listQuerySchema.parse(req.query); const workspaceId = wsId ? await resolveWorkspaceId(wsId) : undefined; const inboxes = await prisma.inbox.findMany({ where: { ...(role ? { role } : {}), ...(workspaceId ? { workspaceId } : {}), }, orderBy: { createdAt: 'desc' }, }); res.json(inboxes.map(stripPassword)); } catch (err) { next(err); } }); router.get('/:id', async (req: Request, res: Response, next: NextFunction) => { try { const inbox = await prisma.inbox.findUnique({ where: { id: req.params.id } }); if (!inbox) { throw new AppError(404, 'Inbox not found'); } res.json(stripPassword(inbox)); } catch (err) { next(err); } }); router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => { try { const data = updateInboxSchema.parse(req.body); const { password, provider, oauthRefreshToken, role, authType, ...rest } = data; if (role) { await validateRoleChange(req.params.id, role); } let connectionUpdates = {}; if (provider) { connectionUpdates = applyProviderPreset(provider, { smtpHost: data.smtpHost, smtpPort: data.smtpPort, imapHost: data.imapHost, imapPort: data.imapPort, }); } const existing = await prisma.inbox.findUnique({ where: { id: req.params.id } }); if (!existing) throw new AppError(404, 'Inbox not found'); const nextAuthType = authType ?? existing.authType; const nextPassword = password ? encrypt(password) : existing.password; const nextOAuth = oauthRefreshToken ? encrypt(oauthRefreshToken) : existing.oauthRefreshToken; if (password || oauthRefreshToken || authType || provider) { const candidate = { ...existing, ...rest, ...(provider ? { provider } : {}), ...connectionUpdates, password: nextPassword, authType: nextAuthType, oauthRefreshToken: nextOAuth, }; const connection = await testConnection(candidate); if (!connection.smtp.ok || !connection.imap.ok) { const errors = [ !connection.smtp.ok ? `SMTP: ${connection.smtp.error}` : null, !connection.imap.ok ? `IMAP: ${connection.imap.error}` : null, ].filter(Boolean); throw new AppError(400, `Connection test failed. ${errors.join(' ')}`); } } const inbox = await prisma.inbox.update({ where: { id: req.params.id }, data: { ...rest, ...(role ? { role } : {}), ...(provider ? { provider } : {}), ...(authType ? { authType } : {}), ...connectionUpdates, ...(password ? { password: encrypt(password) } : {}), ...(oauthRefreshToken ? { oauthRefreshToken: encrypt(oauthRefreshToken) } : {}), ...(rest.status === InboxStatus.active ? { lastError: null, errorAt: null, consecutiveFailures: 0 } : {}), }, }); res.json(stripPassword(inbox)); } catch (err) { next(err); } }); router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => { try { await prisma.inbox.delete({ where: { id: req.params.id } }); res.status(204).send(); } catch (err) { next(err); } }); router.post('/:id/dns-check', async (req: Request, res: Response, next: NextFunction) => { try { const inbox = await prisma.inbox.findUnique({ where: { id: req.params.id } }); if (!inbox) { throw new AppError(404, 'Inbox not found'); } const result = await checkMailboxDns(inbox.email); res.json(result); } catch (err) { next(err); } }); router.post('/:id/test', async (req: Request, res: Response, next: NextFunction) => { try { const inbox = await prisma.inbox.findUnique({ where: { id: req.params.id } }); if (!inbox) { throw new AppError(404, 'Inbox not found'); } const result = await testConnection(inbox); const ok = result.smtp.ok && result.imap.ok; if (!ok) { await prisma.inbox.update({ where: { id: inbox.id }, data: { status: InboxStatus.error, lastError: result.smtp.error ?? result.imap.error ?? 'Connection test failed', errorAt: new Date(), }, }); } else { await prisma.inbox.update({ where: { id: inbox.id }, data: { status: InboxStatus.active, lastError: null, errorAt: null, consecutiveFailures: 0, }, }); } res.json({ ok, ...result }); } catch (err) { next(err); } }); export default router;