Add a new field `consecutiveFailures` to the Inbox model to track consecutive errors. Update the inbox guard logic to mark mailboxes as error after exceeding a failure threshold. Implement retry logic in email and IMAP services to handle transient connection issues. Adjust job processing to reclaim stale send jobs and improve overall reliability of email dispatching. Update routes to initialize `consecutiveFailures` on inbox creation and reset it when the inbox status changes to active.
373 lines
12 KiB
TypeScript
373 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,
|
|
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;
|