warmbox/apps/api/src/routes/inboxes.routes.ts

367 lines
12 KiB
TypeScript

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<typeof createInboxSchema>,
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<void> {
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,
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 }
: {}),
},
});
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 if (inbox.status === InboxStatus.error) {
await prisma.inbox.update({
where: { id: inbox.id },
data: { status: InboxStatus.active, lastError: null, errorAt: null },
});
}
res.json({ ok, ...result });
} catch (err) {
next(err);
}
});
export default router;