367 lines
12 KiB
TypeScript
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;
|