Enhance Inbox model and related services for improved error handling and resilience

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.
This commit is contained in:
Jonny Nguyen 2026-07-03 15:02:41 -07:00
parent 7f47ec4809
commit b300293a72
18 changed files with 515 additions and 109 deletions

File diff suppressed because one or more lines are too long

View file

@ -1247,6 +1247,7 @@ export const InboxScalarFieldEnum = {
companyName: 'companyName', companyName: 'companyName',
lastError: 'lastError', lastError: 'lastError',
errorAt: 'errorAt', errorAt: 'errorAt',
consecutiveFailures: 'consecutiveFailures',
spamFolderPath: 'spamFolderPath', spamFolderPath: 'spamFolderPath',
lastSpamRescuedAt: 'lastSpamRescuedAt', lastSpamRescuedAt: 'lastSpamRescuedAt',
createdAt: 'createdAt', createdAt: 'createdAt',

View file

@ -134,6 +134,7 @@ export const InboxScalarFieldEnum = {
companyName: 'companyName', companyName: 'companyName',
lastError: 'lastError', lastError: 'lastError',
errorAt: 'errorAt', errorAt: 'errorAt',
consecutiveFailures: 'consecutiveFailures',
spamFolderPath: 'spamFolderPath', spamFolderPath: 'spamFolderPath',
lastSpamRescuedAt: 'lastSpamRescuedAt', lastSpamRescuedAt: 'lastSpamRescuedAt',
createdAt: 'createdAt', createdAt: 'createdAt',

View file

@ -31,6 +31,7 @@ export type InboxAvgAggregateOutputType = {
imapPort: number | null imapPort: number | null
dailyLimit: number | null dailyLimit: number | null
currentRamp: number | null currentRamp: number | null
consecutiveFailures: number | null
} }
export type InboxSumAggregateOutputType = { export type InboxSumAggregateOutputType = {
@ -38,6 +39,7 @@ export type InboxSumAggregateOutputType = {
imapPort: number | null imapPort: number | null
dailyLimit: number | null dailyLimit: number | null
currentRamp: number | null currentRamp: number | null
consecutiveFailures: number | null
} }
export type InboxMinAggregateOutputType = { export type InboxMinAggregateOutputType = {
@ -62,6 +64,7 @@ export type InboxMinAggregateOutputType = {
companyName: string | null companyName: string | null
lastError: string | null lastError: string | null
errorAt: Date | null errorAt: Date | null
consecutiveFailures: number | null
spamFolderPath: string | null spamFolderPath: string | null
lastSpamRescuedAt: Date | null lastSpamRescuedAt: Date | null
createdAt: Date | null createdAt: Date | null
@ -90,6 +93,7 @@ export type InboxMaxAggregateOutputType = {
companyName: string | null companyName: string | null
lastError: string | null lastError: string | null
errorAt: Date | null errorAt: Date | null
consecutiveFailures: number | null
spamFolderPath: string | null spamFolderPath: string | null
lastSpamRescuedAt: Date | null lastSpamRescuedAt: Date | null
createdAt: Date | null createdAt: Date | null
@ -118,6 +122,7 @@ export type InboxCountAggregateOutputType = {
companyName: number companyName: number
lastError: number lastError: number
errorAt: number errorAt: number
consecutiveFailures: number
spamFolderPath: number spamFolderPath: number
lastSpamRescuedAt: number lastSpamRescuedAt: number
createdAt: number createdAt: number
@ -131,6 +136,7 @@ export type InboxAvgAggregateInputType = {
imapPort?: true imapPort?: true
dailyLimit?: true dailyLimit?: true
currentRamp?: true currentRamp?: true
consecutiveFailures?: true
} }
export type InboxSumAggregateInputType = { export type InboxSumAggregateInputType = {
@ -138,6 +144,7 @@ export type InboxSumAggregateInputType = {
imapPort?: true imapPort?: true
dailyLimit?: true dailyLimit?: true
currentRamp?: true currentRamp?: true
consecutiveFailures?: true
} }
export type InboxMinAggregateInputType = { export type InboxMinAggregateInputType = {
@ -162,6 +169,7 @@ export type InboxMinAggregateInputType = {
companyName?: true companyName?: true
lastError?: true lastError?: true
errorAt?: true errorAt?: true
consecutiveFailures?: true
spamFolderPath?: true spamFolderPath?: true
lastSpamRescuedAt?: true lastSpamRescuedAt?: true
createdAt?: true createdAt?: true
@ -190,6 +198,7 @@ export type InboxMaxAggregateInputType = {
companyName?: true companyName?: true
lastError?: true lastError?: true
errorAt?: true errorAt?: true
consecutiveFailures?: true
spamFolderPath?: true spamFolderPath?: true
lastSpamRescuedAt?: true lastSpamRescuedAt?: true
createdAt?: true createdAt?: true
@ -218,6 +227,7 @@ export type InboxCountAggregateInputType = {
companyName?: true companyName?: true
lastError?: true lastError?: true
errorAt?: true errorAt?: true
consecutiveFailures?: true
spamFolderPath?: true spamFolderPath?: true
lastSpamRescuedAt?: true lastSpamRescuedAt?: true
createdAt?: true createdAt?: true
@ -333,6 +343,7 @@ export type InboxGroupByOutputType = {
companyName: string | null companyName: string | null
lastError: string | null lastError: string | null
errorAt: Date | null errorAt: Date | null
consecutiveFailures: number
spamFolderPath: string | null spamFolderPath: string | null
lastSpamRescuedAt: Date | null lastSpamRescuedAt: Date | null
createdAt: Date createdAt: Date
@ -384,6 +395,7 @@ export type InboxWhereInput = {
companyName?: Prisma.StringNullableFilter<"Inbox"> | string | null companyName?: Prisma.StringNullableFilter<"Inbox"> | string | null
lastError?: Prisma.StringNullableFilter<"Inbox"> | string | null lastError?: Prisma.StringNullableFilter<"Inbox"> | string | null
errorAt?: Prisma.DateTimeNullableFilter<"Inbox"> | Date | string | null errorAt?: Prisma.DateTimeNullableFilter<"Inbox"> | Date | string | null
consecutiveFailures?: Prisma.IntFilter<"Inbox"> | number
spamFolderPath?: Prisma.StringNullableFilter<"Inbox"> | string | null spamFolderPath?: Prisma.StringNullableFilter<"Inbox"> | string | null
lastSpamRescuedAt?: Prisma.DateTimeNullableFilter<"Inbox"> | Date | string | null lastSpamRescuedAt?: Prisma.DateTimeNullableFilter<"Inbox"> | Date | string | null
createdAt?: Prisma.DateTimeFilter<"Inbox"> | Date | string createdAt?: Prisma.DateTimeFilter<"Inbox"> | Date | string
@ -417,6 +429,7 @@ export type InboxOrderByWithRelationInput = {
companyName?: Prisma.SortOrderInput | Prisma.SortOrder companyName?: Prisma.SortOrderInput | Prisma.SortOrder
lastError?: Prisma.SortOrderInput | Prisma.SortOrder lastError?: Prisma.SortOrderInput | Prisma.SortOrder
errorAt?: Prisma.SortOrderInput | Prisma.SortOrder errorAt?: Prisma.SortOrderInput | Prisma.SortOrder
consecutiveFailures?: Prisma.SortOrder
spamFolderPath?: Prisma.SortOrderInput | Prisma.SortOrder spamFolderPath?: Prisma.SortOrderInput | Prisma.SortOrder
lastSpamRescuedAt?: Prisma.SortOrderInput | Prisma.SortOrder lastSpamRescuedAt?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
@ -454,6 +467,7 @@ export type InboxWhereUniqueInput = Prisma.AtLeast<{
companyName?: Prisma.StringNullableFilter<"Inbox"> | string | null companyName?: Prisma.StringNullableFilter<"Inbox"> | string | null
lastError?: Prisma.StringNullableFilter<"Inbox"> | string | null lastError?: Prisma.StringNullableFilter<"Inbox"> | string | null
errorAt?: Prisma.DateTimeNullableFilter<"Inbox"> | Date | string | null errorAt?: Prisma.DateTimeNullableFilter<"Inbox"> | Date | string | null
consecutiveFailures?: Prisma.IntFilter<"Inbox"> | number
spamFolderPath?: Prisma.StringNullableFilter<"Inbox"> | string | null spamFolderPath?: Prisma.StringNullableFilter<"Inbox"> | string | null
lastSpamRescuedAt?: Prisma.DateTimeNullableFilter<"Inbox"> | Date | string | null lastSpamRescuedAt?: Prisma.DateTimeNullableFilter<"Inbox"> | Date | string | null
createdAt?: Prisma.DateTimeFilter<"Inbox"> | Date | string createdAt?: Prisma.DateTimeFilter<"Inbox"> | Date | string
@ -487,6 +501,7 @@ export type InboxOrderByWithAggregationInput = {
companyName?: Prisma.SortOrderInput | Prisma.SortOrder companyName?: Prisma.SortOrderInput | Prisma.SortOrder
lastError?: Prisma.SortOrderInput | Prisma.SortOrder lastError?: Prisma.SortOrderInput | Prisma.SortOrder
errorAt?: Prisma.SortOrderInput | Prisma.SortOrder errorAt?: Prisma.SortOrderInput | Prisma.SortOrder
consecutiveFailures?: Prisma.SortOrder
spamFolderPath?: Prisma.SortOrderInput | Prisma.SortOrder spamFolderPath?: Prisma.SortOrderInput | Prisma.SortOrder
lastSpamRescuedAt?: Prisma.SortOrderInput | Prisma.SortOrder lastSpamRescuedAt?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
@ -523,6 +538,7 @@ export type InboxScalarWhereWithAggregatesInput = {
companyName?: Prisma.StringNullableWithAggregatesFilter<"Inbox"> | string | null companyName?: Prisma.StringNullableWithAggregatesFilter<"Inbox"> | string | null
lastError?: Prisma.StringNullableWithAggregatesFilter<"Inbox"> | string | null lastError?: Prisma.StringNullableWithAggregatesFilter<"Inbox"> | string | null
errorAt?: Prisma.DateTimeNullableWithAggregatesFilter<"Inbox"> | Date | string | null errorAt?: Prisma.DateTimeNullableWithAggregatesFilter<"Inbox"> | Date | string | null
consecutiveFailures?: Prisma.IntWithAggregatesFilter<"Inbox"> | number
spamFolderPath?: Prisma.StringNullableWithAggregatesFilter<"Inbox"> | string | null spamFolderPath?: Prisma.StringNullableWithAggregatesFilter<"Inbox"> | string | null
lastSpamRescuedAt?: Prisma.DateTimeNullableWithAggregatesFilter<"Inbox"> | Date | string | null lastSpamRescuedAt?: Prisma.DateTimeNullableWithAggregatesFilter<"Inbox"> | Date | string | null
createdAt?: Prisma.DateTimeWithAggregatesFilter<"Inbox"> | Date | string createdAt?: Prisma.DateTimeWithAggregatesFilter<"Inbox"> | Date | string
@ -550,6 +566,7 @@ export type InboxCreateInput = {
companyName?: string | null companyName?: string | null
lastError?: string | null lastError?: string | null
errorAt?: Date | string | null errorAt?: Date | string | null
consecutiveFailures?: number
spamFolderPath?: string | null spamFolderPath?: string | null
lastSpamRescuedAt?: Date | string | null lastSpamRescuedAt?: Date | string | null
createdAt?: Date | string createdAt?: Date | string
@ -583,6 +600,7 @@ export type InboxUncheckedCreateInput = {
companyName?: string | null companyName?: string | null
lastError?: string | null lastError?: string | null
errorAt?: Date | string | null errorAt?: Date | string | null
consecutiveFailures?: number
spamFolderPath?: string | null spamFolderPath?: string | null
lastSpamRescuedAt?: Date | string | null lastSpamRescuedAt?: Date | string | null
createdAt?: Date | string createdAt?: Date | string
@ -614,6 +632,7 @@ export type InboxUpdateInput = {
companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
consecutiveFailures?: Prisma.IntFieldUpdateOperationsInput | number
spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@ -647,6 +666,7 @@ export type InboxUncheckedUpdateInput = {
companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
consecutiveFailures?: Prisma.IntFieldUpdateOperationsInput | number
spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@ -679,6 +699,7 @@ export type InboxCreateManyInput = {
companyName?: string | null companyName?: string | null
lastError?: string | null lastError?: string | null
errorAt?: Date | string | null errorAt?: Date | string | null
consecutiveFailures?: number
spamFolderPath?: string | null spamFolderPath?: string | null
lastSpamRescuedAt?: Date | string | null lastSpamRescuedAt?: Date | string | null
createdAt?: Date | string createdAt?: Date | string
@ -706,6 +727,7 @@ export type InboxUpdateManyMutationInput = {
companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
consecutiveFailures?: Prisma.IntFieldUpdateOperationsInput | number
spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@ -734,6 +756,7 @@ export type InboxUncheckedUpdateManyInput = {
companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
consecutiveFailures?: Prisma.IntFieldUpdateOperationsInput | number
spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@ -777,6 +800,7 @@ export type InboxCountOrderByAggregateInput = {
companyName?: Prisma.SortOrder companyName?: Prisma.SortOrder
lastError?: Prisma.SortOrder lastError?: Prisma.SortOrder
errorAt?: Prisma.SortOrder errorAt?: Prisma.SortOrder
consecutiveFailures?: Prisma.SortOrder
spamFolderPath?: Prisma.SortOrder spamFolderPath?: Prisma.SortOrder
lastSpamRescuedAt?: Prisma.SortOrder lastSpamRescuedAt?: Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
@ -788,6 +812,7 @@ export type InboxAvgOrderByAggregateInput = {
imapPort?: Prisma.SortOrder imapPort?: Prisma.SortOrder
dailyLimit?: Prisma.SortOrder dailyLimit?: Prisma.SortOrder
currentRamp?: Prisma.SortOrder currentRamp?: Prisma.SortOrder
consecutiveFailures?: Prisma.SortOrder
} }
export type InboxMaxOrderByAggregateInput = { export type InboxMaxOrderByAggregateInput = {
@ -812,6 +837,7 @@ export type InboxMaxOrderByAggregateInput = {
companyName?: Prisma.SortOrder companyName?: Prisma.SortOrder
lastError?: Prisma.SortOrder lastError?: Prisma.SortOrder
errorAt?: Prisma.SortOrder errorAt?: Prisma.SortOrder
consecutiveFailures?: Prisma.SortOrder
spamFolderPath?: Prisma.SortOrder spamFolderPath?: Prisma.SortOrder
lastSpamRescuedAt?: Prisma.SortOrder lastSpamRescuedAt?: Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
@ -840,6 +866,7 @@ export type InboxMinOrderByAggregateInput = {
companyName?: Prisma.SortOrder companyName?: Prisma.SortOrder
lastError?: Prisma.SortOrder lastError?: Prisma.SortOrder
errorAt?: Prisma.SortOrder errorAt?: Prisma.SortOrder
consecutiveFailures?: Prisma.SortOrder
spamFolderPath?: Prisma.SortOrder spamFolderPath?: Prisma.SortOrder
lastSpamRescuedAt?: Prisma.SortOrder lastSpamRescuedAt?: Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
@ -851,6 +878,7 @@ export type InboxSumOrderByAggregateInput = {
imapPort?: Prisma.SortOrder imapPort?: Prisma.SortOrder
dailyLimit?: Prisma.SortOrder dailyLimit?: Prisma.SortOrder
currentRamp?: Prisma.SortOrder currentRamp?: Prisma.SortOrder
consecutiveFailures?: Prisma.SortOrder
} }
export type InboxScalarRelationFilter = { export type InboxScalarRelationFilter = {
@ -1001,6 +1029,7 @@ export type InboxCreateWithoutWorkspaceInput = {
companyName?: string | null companyName?: string | null
lastError?: string | null lastError?: string | null
errorAt?: Date | string | null errorAt?: Date | string | null
consecutiveFailures?: number
spamFolderPath?: string | null spamFolderPath?: string | null
lastSpamRescuedAt?: Date | string | null lastSpamRescuedAt?: Date | string | null
createdAt?: Date | string createdAt?: Date | string
@ -1032,6 +1061,7 @@ export type InboxUncheckedCreateWithoutWorkspaceInput = {
companyName?: string | null companyName?: string | null
lastError?: string | null lastError?: string | null
errorAt?: Date | string | null errorAt?: Date | string | null
consecutiveFailures?: number
spamFolderPath?: string | null spamFolderPath?: string | null
lastSpamRescuedAt?: Date | string | null lastSpamRescuedAt?: Date | string | null
createdAt?: Date | string createdAt?: Date | string
@ -1092,6 +1122,7 @@ export type InboxScalarWhereInput = {
companyName?: Prisma.StringNullableFilter<"Inbox"> | string | null companyName?: Prisma.StringNullableFilter<"Inbox"> | string | null
lastError?: Prisma.StringNullableFilter<"Inbox"> | string | null lastError?: Prisma.StringNullableFilter<"Inbox"> | string | null
errorAt?: Prisma.DateTimeNullableFilter<"Inbox"> | Date | string | null errorAt?: Prisma.DateTimeNullableFilter<"Inbox"> | Date | string | null
consecutiveFailures?: Prisma.IntFilter<"Inbox"> | number
spamFolderPath?: Prisma.StringNullableFilter<"Inbox"> | string | null spamFolderPath?: Prisma.StringNullableFilter<"Inbox"> | string | null
lastSpamRescuedAt?: Prisma.DateTimeNullableFilter<"Inbox"> | Date | string | null lastSpamRescuedAt?: Prisma.DateTimeNullableFilter<"Inbox"> | Date | string | null
createdAt?: Prisma.DateTimeFilter<"Inbox"> | Date | string createdAt?: Prisma.DateTimeFilter<"Inbox"> | Date | string
@ -1119,6 +1150,7 @@ export type InboxCreateWithoutPrimaryCampaignsInput = {
companyName?: string | null companyName?: string | null
lastError?: string | null lastError?: string | null
errorAt?: Date | string | null errorAt?: Date | string | null
consecutiveFailures?: number
spamFolderPath?: string | null spamFolderPath?: string | null
lastSpamRescuedAt?: Date | string | null lastSpamRescuedAt?: Date | string | null
createdAt?: Date | string createdAt?: Date | string
@ -1151,6 +1183,7 @@ export type InboxUncheckedCreateWithoutPrimaryCampaignsInput = {
companyName?: string | null companyName?: string | null
lastError?: string | null lastError?: string | null
errorAt?: Date | string | null errorAt?: Date | string | null
consecutiveFailures?: number
spamFolderPath?: string | null spamFolderPath?: string | null
lastSpamRescuedAt?: Date | string | null lastSpamRescuedAt?: Date | string | null
createdAt?: Date | string createdAt?: Date | string
@ -1197,6 +1230,7 @@ export type InboxUpdateWithoutPrimaryCampaignsInput = {
companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
consecutiveFailures?: Prisma.IntFieldUpdateOperationsInput | number
spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@ -1229,6 +1263,7 @@ export type InboxUncheckedUpdateWithoutPrimaryCampaignsInput = {
companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
consecutiveFailures?: Prisma.IntFieldUpdateOperationsInput | number
spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@ -1259,6 +1294,7 @@ export type InboxCreateWithoutSatelliteCampaignsInput = {
companyName?: string | null companyName?: string | null
lastError?: string | null lastError?: string | null
errorAt?: Date | string | null errorAt?: Date | string | null
consecutiveFailures?: number
spamFolderPath?: string | null spamFolderPath?: string | null
lastSpamRescuedAt?: Date | string | null lastSpamRescuedAt?: Date | string | null
createdAt?: Date | string createdAt?: Date | string
@ -1291,6 +1327,7 @@ export type InboxUncheckedCreateWithoutSatelliteCampaignsInput = {
companyName?: string | null companyName?: string | null
lastError?: string | null lastError?: string | null
errorAt?: Date | string | null errorAt?: Date | string | null
consecutiveFailures?: number
spamFolderPath?: string | null spamFolderPath?: string | null
lastSpamRescuedAt?: Date | string | null lastSpamRescuedAt?: Date | string | null
createdAt?: Date | string createdAt?: Date | string
@ -1337,6 +1374,7 @@ export type InboxUpdateWithoutSatelliteCampaignsInput = {
companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
consecutiveFailures?: Prisma.IntFieldUpdateOperationsInput | number
spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@ -1369,6 +1407,7 @@ export type InboxUncheckedUpdateWithoutSatelliteCampaignsInput = {
companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
consecutiveFailures?: Prisma.IntFieldUpdateOperationsInput | number
spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@ -1399,6 +1438,7 @@ export type InboxCreateWithoutSentLogsInput = {
companyName?: string | null companyName?: string | null
lastError?: string | null lastError?: string | null
errorAt?: Date | string | null errorAt?: Date | string | null
consecutiveFailures?: number
spamFolderPath?: string | null spamFolderPath?: string | null
lastSpamRescuedAt?: Date | string | null lastSpamRescuedAt?: Date | string | null
createdAt?: Date | string createdAt?: Date | string
@ -1431,6 +1471,7 @@ export type InboxUncheckedCreateWithoutSentLogsInput = {
companyName?: string | null companyName?: string | null
lastError?: string | null lastError?: string | null
errorAt?: Date | string | null errorAt?: Date | string | null
consecutiveFailures?: number
spamFolderPath?: string | null spamFolderPath?: string | null
lastSpamRescuedAt?: Date | string | null lastSpamRescuedAt?: Date | string | null
createdAt?: Date | string createdAt?: Date | string
@ -1466,6 +1507,7 @@ export type InboxCreateWithoutReceivedLogsInput = {
companyName?: string | null companyName?: string | null
lastError?: string | null lastError?: string | null
errorAt?: Date | string | null errorAt?: Date | string | null
consecutiveFailures?: number
spamFolderPath?: string | null spamFolderPath?: string | null
lastSpamRescuedAt?: Date | string | null lastSpamRescuedAt?: Date | string | null
createdAt?: Date | string createdAt?: Date | string
@ -1498,6 +1540,7 @@ export type InboxUncheckedCreateWithoutReceivedLogsInput = {
companyName?: string | null companyName?: string | null
lastError?: string | null lastError?: string | null
errorAt?: Date | string | null errorAt?: Date | string | null
consecutiveFailures?: number
spamFolderPath?: string | null spamFolderPath?: string | null
lastSpamRescuedAt?: Date | string | null lastSpamRescuedAt?: Date | string | null
createdAt?: Date | string createdAt?: Date | string
@ -1544,6 +1587,7 @@ export type InboxUpdateWithoutSentLogsInput = {
companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
consecutiveFailures?: Prisma.IntFieldUpdateOperationsInput | number
spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@ -1576,6 +1620,7 @@ export type InboxUncheckedUpdateWithoutSentLogsInput = {
companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
consecutiveFailures?: Prisma.IntFieldUpdateOperationsInput | number
spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@ -1617,6 +1662,7 @@ export type InboxUpdateWithoutReceivedLogsInput = {
companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
consecutiveFailures?: Prisma.IntFieldUpdateOperationsInput | number
spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@ -1649,6 +1695,7 @@ export type InboxUncheckedUpdateWithoutReceivedLogsInput = {
companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
consecutiveFailures?: Prisma.IntFieldUpdateOperationsInput | number
spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@ -1679,6 +1726,7 @@ export type InboxCreateManyWorkspaceInput = {
companyName?: string | null companyName?: string | null
lastError?: string | null lastError?: string | null
errorAt?: Date | string | null errorAt?: Date | string | null
consecutiveFailures?: number
spamFolderPath?: string | null spamFolderPath?: string | null
lastSpamRescuedAt?: Date | string | null lastSpamRescuedAt?: Date | string | null
createdAt?: Date | string createdAt?: Date | string
@ -1706,6 +1754,7 @@ export type InboxUpdateWithoutWorkspaceInput = {
companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
consecutiveFailures?: Prisma.IntFieldUpdateOperationsInput | number
spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@ -1737,6 +1786,7 @@ export type InboxUncheckedUpdateWithoutWorkspaceInput = {
companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
consecutiveFailures?: Prisma.IntFieldUpdateOperationsInput | number
spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@ -1768,6 +1818,7 @@ export type InboxUncheckedUpdateManyWithoutWorkspaceInput = {
companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null lastError?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null errorAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
consecutiveFailures?: Prisma.IntFieldUpdateOperationsInput | number
spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null spamFolderPath?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null lastSpamRescuedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@ -1854,6 +1905,7 @@ export type InboxSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs =
companyName?: boolean companyName?: boolean
lastError?: boolean lastError?: boolean
errorAt?: boolean errorAt?: boolean
consecutiveFailures?: boolean
spamFolderPath?: boolean spamFolderPath?: boolean
lastSpamRescuedAt?: boolean lastSpamRescuedAt?: boolean
createdAt?: boolean createdAt?: boolean
@ -1888,6 +1940,7 @@ export type InboxSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensi
companyName?: boolean companyName?: boolean
lastError?: boolean lastError?: boolean
errorAt?: boolean errorAt?: boolean
consecutiveFailures?: boolean
spamFolderPath?: boolean spamFolderPath?: boolean
lastSpamRescuedAt?: boolean lastSpamRescuedAt?: boolean
createdAt?: boolean createdAt?: boolean
@ -1917,6 +1970,7 @@ export type InboxSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensi
companyName?: boolean companyName?: boolean
lastError?: boolean lastError?: boolean
errorAt?: boolean errorAt?: boolean
consecutiveFailures?: boolean
spamFolderPath?: boolean spamFolderPath?: boolean
lastSpamRescuedAt?: boolean lastSpamRescuedAt?: boolean
createdAt?: boolean createdAt?: boolean
@ -1946,13 +2000,14 @@ export type InboxSelectScalar = {
companyName?: boolean companyName?: boolean
lastError?: boolean lastError?: boolean
errorAt?: boolean errorAt?: boolean
consecutiveFailures?: boolean
spamFolderPath?: boolean spamFolderPath?: boolean
lastSpamRescuedAt?: boolean lastSpamRescuedAt?: boolean
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
} }
export type InboxOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "workspaceId" | "email" | "smtpHost" | "smtpPort" | "imapHost" | "imapPort" | "username" | "password" | "authType" | "oauthRefreshToken" | "role" | "provider" | "status" | "dailyLimit" | "currentRamp" | "industry" | "senderName" | "companyName" | "lastError" | "errorAt" | "spamFolderPath" | "lastSpamRescuedAt" | "createdAt" | "updatedAt", ExtArgs["result"]["inbox"]> export type InboxOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "workspaceId" | "email" | "smtpHost" | "smtpPort" | "imapHost" | "imapPort" | "username" | "password" | "authType" | "oauthRefreshToken" | "role" | "provider" | "status" | "dailyLimit" | "currentRamp" | "industry" | "senderName" | "companyName" | "lastError" | "errorAt" | "consecutiveFailures" | "spamFolderPath" | "lastSpamRescuedAt" | "createdAt" | "updatedAt", ExtArgs["result"]["inbox"]>
export type InboxInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type InboxInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
workspace?: boolean | Prisma.WorkspaceDefaultArgs<ExtArgs> workspace?: boolean | Prisma.WorkspaceDefaultArgs<ExtArgs>
sentLogs?: boolean | Prisma.Inbox$sentLogsArgs<ExtArgs> sentLogs?: boolean | Prisma.Inbox$sentLogsArgs<ExtArgs>
@ -1999,6 +2054,7 @@ export type $InboxPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs
companyName: string | null companyName: string | null
lastError: string | null lastError: string | null
errorAt: Date | null errorAt: Date | null
consecutiveFailures: number
spamFolderPath: string | null spamFolderPath: string | null
lastSpamRescuedAt: Date | null lastSpamRescuedAt: Date | null
createdAt: Date createdAt: Date
@ -2452,6 +2508,7 @@ export interface InboxFieldRefs {
readonly companyName: Prisma.FieldRef<"Inbox", 'String'> readonly companyName: Prisma.FieldRef<"Inbox", 'String'>
readonly lastError: Prisma.FieldRef<"Inbox", 'String'> readonly lastError: Prisma.FieldRef<"Inbox", 'String'>
readonly errorAt: Prisma.FieldRef<"Inbox", 'DateTime'> readonly errorAt: Prisma.FieldRef<"Inbox", 'DateTime'>
readonly consecutiveFailures: Prisma.FieldRef<"Inbox", 'Int'>
readonly spamFolderPath: Prisma.FieldRef<"Inbox", 'String'> readonly spamFolderPath: Prisma.FieldRef<"Inbox", 'String'>
readonly lastSpamRescuedAt: Prisma.FieldRef<"Inbox", 'DateTime'> readonly lastSpamRescuedAt: Prisma.FieldRef<"Inbox", 'DateTime'>
readonly createdAt: Prisma.FieldRef<"Inbox", 'DateTime'> readonly createdAt: Prisma.FieldRef<"Inbox", 'DateTime'>

View file

@ -11,6 +11,7 @@ import {
claimDueSendJob, claimDueSendJob,
completeSendJob, completeSendJob,
enqueueSendJob, enqueueSendJob,
reclaimStaleSendJobs,
} from '../services/job-queue.service'; } from '../services/job-queue.service';
const MIN_JITTER_MS = 45 * 60 * 1000; const MIN_JITTER_MS = 45 * 60 * 1000;
@ -79,12 +80,19 @@ async function executeCampaignSend(
const satellite = pickWeightedSatellite(activeSatellites); const satellite = pickWeightedSatellite(activeSatellites);
const primary = campaign.primaryMailbox; const primary = campaign.primaryMailbox;
if (primary.status !== InboxStatus.active) { if (primary.status === InboxStatus.paused) {
logger.warn({ campaignId }, 'Primary mailbox is not active'); logger.warn({ campaignId }, 'Primary mailbox is paused — skipping send');
if (jobId) await completeSendJob(jobId, false, 'Primary mailbox inactive'); if (jobId) await completeSendJob(jobId, false, 'Primary mailbox paused');
return; return;
} }
if (primary.status === InboxStatus.error) {
logger.warn(
{ campaignId, primary: primary.email },
'Primary mailbox in error — proceeding with satellite send (IMAP not required)',
);
}
if (!isAllowedWarmupSendPair(satellite, primary)) { if (!isAllowedWarmupSendPair(satellite, primary)) {
logger.warn( logger.warn(
{ campaignId, satellite: satellite.email, primary: primary.email }, { campaignId, satellite: satellite.email, primary: primary.email },
@ -94,13 +102,14 @@ async function executeCampaignSend(
return; return;
} }
await withInboxGuard(satellite, async () => { try {
const { subject, body, validationResult } = const { subject, body, validationResult } =
await aiService.generateInitialEmailWithValidation(primary.industry, { await aiService.generateInitialEmailWithValidation(primary.industry, {
senderName: satellite.senderName, senderName: satellite.senderName,
companyName: satellite.companyName, companyName: satellite.companyName,
}); });
await withInboxGuard(satellite, async () => {
const { messageId } = await emailService.sendMail(satellite, { const { messageId } = await emailService.sendMail(satellite, {
to: primary.email, to: primary.email,
subject, subject,
@ -139,8 +148,6 @@ async function executeCampaignSend(
}); });
}); });
if (jobId) await completeSendJob(jobId, true);
logger.info( logger.info(
{ {
campaignId: campaign.id, campaignId: campaign.id,
@ -151,11 +158,20 @@ async function executeCampaignSend(
'Campaign warmup email dispatched', 'Campaign warmup email dispatched',
); );
}); });
if (jobId) await completeSendJob(jobId, true);
} catch (err) {
const message = err instanceof Error ? err.message : 'Campaign send failed';
if (jobId) await completeSendJob(jobId, false, message);
throw err;
}
} }
export async function runCampaignDispatcherJob(): Promise<string> { export async function runCampaignDispatcherJob(): Promise<string> {
logger.info('Campaign dispatcher job started'); logger.info('Campaign dispatcher job started');
await reclaimStaleSendJobs();
const campaigns = await prisma.warmupCampaign.findMany({ const campaigns = await prisma.warmupCampaign.findMany({
where: { where: {
status: { in: [CampaignStatus.active, CampaignStatus.maintenance] }, status: { in: [CampaignStatus.active, CampaignStatus.maintenance] },

View file

@ -93,11 +93,12 @@ export async function runDispatcherJob(): Promise<void> {
continue; continue;
} }
await withInboxGuard(sender, async () => {
const { subject, body } = await aiService.generateInitialEmail(sender.industry, { const { subject, body } = await aiService.generateInitialEmail(sender.industry, {
senderName: sender.senderName, senderName: sender.senderName,
companyName: sender.companyName, companyName: sender.companyName,
}); });
await withInboxGuard(sender, async () => {
const { messageId } = await emailService.sendMail(sender, { const { messageId } = await emailService.sendMail(sender, {
to: receiver.email, to: receiver.email,
subject, subject,

View file

@ -458,7 +458,7 @@ export async function runRescuerJob(): Promise<void> {
for (const primaryMailboxId of primaryIds) { for (const primaryMailboxId of primaryIds) {
const inbox = await prisma.inbox.findUnique({ where: { id: primaryMailboxId } }); const inbox = await prisma.inbox.findUnique({ where: { id: primaryMailboxId } });
if (!inbox || inbox.status !== InboxStatus.active) continue; if (!inbox || inbox.status === InboxStatus.paused) continue;
try { try {
await processCampaignPrimary(inbox); await processCampaignPrimary(inbox);

View file

@ -1,6 +1,10 @@
import type { Inbox } from '../lib/prisma'; import type { Inbox } from '../lib/prisma';
import { InboxStatus } from '../lib/prisma'; import { InboxStatus } from '../lib/prisma';
import { prisma } from '../lib/prisma'; import { prisma } from '../lib/prisma';
import { logger } from './logger';
/** Consecutive failed operations before marking a mailbox as error. */
export const MAILBOX_FAILURE_THRESHOLD = 3;
export async function withInboxGuard<T>( export async function withInboxGuard<T>(
inbox: Inbox, inbox: Inbox,
@ -9,24 +13,49 @@ export async function withInboxGuard<T>(
try { try {
const result = await fn(); const result = await fn();
if (inbox.status === InboxStatus.error) {
await prisma.inbox.update({ await prisma.inbox.update({
where: { id: inbox.id }, where: { id: inbox.id },
data: { status: InboxStatus.active, lastError: null, errorAt: null }, data: {
}); consecutiveFailures: 0,
...(inbox.status === InboxStatus.error
? {
status: InboxStatus.active,
lastError: null,
errorAt: null,
} }
: {}),
},
});
return result; return result;
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'; const message = err instanceof Error ? err.message : 'Unknown error';
await prisma.inbox.update({ const updated = await prisma.inbox.update({
where: { id: inbox.id }, where: { id: inbox.id },
data: { data: {
status: InboxStatus.error, consecutiveFailures: { increment: 1 },
lastError: message, lastError: message,
errorAt: new Date(), errorAt: new Date(),
}, },
select: { consecutiveFailures: true },
}); });
if (updated.consecutiveFailures >= MAILBOX_FAILURE_THRESHOLD) {
await prisma.inbox.update({
where: { id: inbox.id },
data: { status: InboxStatus.error },
});
logger.error(
{ inboxId: inbox.id, email: inbox.email, consecutiveFailures: updated.consecutiveFailures, message },
'Mailbox marked error after consecutive failures',
);
} else {
logger.warn(
{ inboxId: inbox.id, email: inbox.email, consecutiveFailures: updated.consecutiveFailures, message },
'Mailbox operation failed (still active)',
);
}
throw err; throw err;
} }
} }

View file

@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { isRetryableConnectionError, withRetry } from './retry';
describe('retry', () => {
it('treats timeouts as retryable', () => {
assert.equal(isRetryableConnectionError(new Error('Connection timeout')), true);
assert.equal(isRetryableConnectionError(new Error('ETIMEDOUT')), true);
});
it('treats auth failures as non-retryable', () => {
assert.equal(isRetryableConnectionError(new Error('Invalid credentials')), false);
assert.equal(isRetryableConnectionError(new Error('Authentication failed')), false);
});
it('returns on first success', async () => {
let calls = 0;
const result = await withRetry(async () => {
calls += 1;
return 'ok';
});
assert.equal(result, 'ok');
assert.equal(calls, 1);
});
it('retries transient errors then succeeds', async () => {
let calls = 0;
const result = await withRetry(
async () => {
calls += 1;
if (calls === 1) {
throw new Error('ETIMEDOUT');
}
return 'ok';
},
{ baseDelayMs: 1 },
);
assert.equal(result, 'ok');
assert.equal(calls, 2);
});
it('does not retry auth errors', async () => {
let calls = 0;
await assert.rejects(
withRetry(async () => {
calls += 1;
throw new Error('Invalid credentials');
}),
/Invalid credentials/,
);
assert.equal(calls, 1);
});
});

98
apps/api/src/lib/retry.ts Normal file
View file

@ -0,0 +1,98 @@
import { logger } from './logger';
const DEFAULT_MAX_ATTEMPTS = 3;
const DEFAULT_BASE_DELAY_MS = 1_000;
const NON_RETRYABLE_PATTERNS = [
'invalid credentials',
'authentication failed',
'authenticationfailed',
'invalid login',
'username and password not accepted',
'oauth',
'not configured',
'validation failed',
'content validation',
];
const RETRYABLE_PATTERNS = [
'timeout',
'etimedout',
'econnreset',
'econnrefused',
'enotfound',
'socket',
'connection lost',
'connection closed',
'too many',
'rate limit',
'temporarily',
'try again',
'service unavailable',
'unavailable',
'503',
'421',
'454',
'4.7.0',
'system error',
'network',
'closed',
'broken pipe',
'eai_again',
];
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
export function isRetryableConnectionError(err: unknown): boolean {
const message = errorMessage(err).toLowerCase();
if (NON_RETRYABLE_PATTERNS.some((pattern) => message.includes(pattern))) {
return false;
}
return RETRYABLE_PATTERNS.some((pattern) => message.includes(pattern));
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function withRetry<T>(
fn: () => Promise<T>,
options?: {
maxAttempts?: number;
baseDelayMs?: number;
label?: string;
},
): Promise<T> {
const maxAttempts = options?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
const baseDelayMs = options?.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
const label = options?.label ?? 'operation';
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
const retryable = isRetryableConnectionError(err);
const isLastAttempt = attempt === maxAttempts;
if (!retryable || isLastAttempt) {
throw err;
}
const delayMs = baseDelayMs * 2 ** (attempt - 1);
logger.warn(
{ attempt, maxAttempts, delayMs, label, err: errorMessage(err) },
'Retrying after transient connection failure',
);
await sleep(delayMs);
}
}
throw lastError;
}

View file

@ -13,7 +13,7 @@ export const DEFAULT_INDUSTRIES = ['SaaS', 'Marketing', 'Finance', 'Childcare']
export const DEFAULT_SETTINGS = { export const DEFAULT_SETTINGS = {
dispatcher_cron: '*/15 * * * *', dispatcher_cron: '*/15 * * * *',
rescuer_cron: '*/10 * * * *', rescuer_cron: '*/20 * * * *',
ramp_increment: '2', ramp_increment: '2',
industries: JSON.stringify(DEFAULT_INDUSTRIES), industries: JSON.stringify(DEFAULT_INDUSTRIES),
} as const; } as const;

View file

@ -170,6 +170,7 @@ router.post('/', async (req: Request, res: Response, next: NextFunction) => {
companyName: data.companyName ?? null, companyName: data.companyName ?? null,
lastError: null, lastError: null,
errorAt: null, errorAt: null,
consecutiveFailures: 0,
spamFolderPath: null, spamFolderPath: null,
lastSpamRescuedAt: null, lastSpamRescuedAt: null,
createdAt: new Date(), createdAt: new Date(),
@ -300,7 +301,7 @@ router.patch('/:id', async (req: Request, res: Response, next: NextFunction) =>
...(password ? { password: encrypt(password) } : {}), ...(password ? { password: encrypt(password) } : {}),
...(oauthRefreshToken ? { oauthRefreshToken: encrypt(oauthRefreshToken) } : {}), ...(oauthRefreshToken ? { oauthRefreshToken: encrypt(oauthRefreshToken) } : {}),
...(rest.status === InboxStatus.active ...(rest.status === InboxStatus.active
? { lastError: null, errorAt: null } ? { lastError: null, errorAt: null, consecutiveFailures: 0 }
: {}), : {}),
}, },
}); });
@ -351,10 +352,15 @@ router.post('/:id/test', async (req: Request, res: Response, next: NextFunction)
errorAt: new Date(), errorAt: new Date(),
}, },
}); });
} else if (inbox.status === InboxStatus.error) { } else {
await prisma.inbox.update({ await prisma.inbox.update({
where: { id: inbox.id }, where: { id: inbox.id },
data: { status: InboxStatus.active, lastError: null, errorAt: null }, data: {
status: InboxStatus.active,
lastError: null,
errorAt: null,
consecutiveFailures: 0,
},
}); });
} }

View file

@ -1,6 +1,7 @@
import nodemailer, { Transporter } from 'nodemailer'; import nodemailer, { Transporter } from 'nodemailer';
import type { Inbox } from '../lib/prisma'; import type { Inbox } from '../lib/prisma';
import { MailboxAuthType } from '../lib/prisma'; import { MailboxAuthType } from '../lib/prisma';
import { withRetry } from '../lib/retry';
import { decrypt } from './crypto.service'; import { decrypt } from './crypto.service';
import { EmailPayload } from '../models/types'; import { EmailPayload } from '../models/types';
import { resolveMailboxAccessToken } from './mailbox-auth.service'; import { resolveMailboxAccessToken } from './mailbox-auth.service';
@ -58,9 +59,18 @@ export async function createSmtpTransport(inbox: Inbox): Promise<Transporter> {
export async function testSmtp( export async function testSmtp(
inbox: Inbox, inbox: Inbox,
): Promise<{ ok: boolean; error?: string }> { ): Promise<{ ok: boolean; error?: string }> {
try {
await withRetry(
async () => {
const transporter = await createSmtpTransport(inbox); const transporter = await createSmtpTransport(inbox);
try { try {
await transporter.verify(); await transporter.verify();
} finally {
transporter.close();
}
},
{ label: `smtp-test:${inbox.email}` },
);
return { ok: true }; return { ok: true };
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'SMTP verification failed'; const message = err instanceof Error ? err.message : 'SMTP verification failed';
@ -68,8 +78,6 @@ export async function testSmtp(
ok: false, ok: false,
error: formatMicrosoftAuthError(message), error: formatMicrosoftAuthError(message),
}; };
} finally {
transporter.close();
} }
} }
@ -77,6 +85,8 @@ export async function sendMail(
inbox: Inbox, inbox: Inbox,
payload: EmailPayload, payload: EmailPayload,
): Promise<{ messageId: string }> { ): Promise<{ messageId: string }> {
return withRetry(
async () => {
const transporter = await createSmtpTransport(inbox); const transporter = await createSmtpTransport(inbox);
try { try {
@ -94,6 +104,9 @@ export async function sendMail(
} finally { } finally {
transporter.close(); transporter.close();
} }
},
{ label: `smtp-send:${inbox.email}` },
);
} }
export async function testConnection( export async function testConnection(

View file

@ -1,6 +1,7 @@
import { ImapFlow } from 'imapflow'; import { ImapFlow } from 'imapflow';
import type { Inbox } from '../lib/prisma'; import type { Inbox } from '../lib/prisma';
import { MailboxAuthType } from '../lib/prisma'; import { MailboxAuthType } from '../lib/prisma';
import { withRetry } from '../lib/retry';
import { decrypt } from './crypto.service'; import { decrypt } from './crypto.service';
import { resolveMailboxAccessToken } from './mailbox-auth.service'; import { resolveMailboxAccessToken } from './mailbox-auth.service';
import { formatMicrosoftAuthError } from './microsoft-oauth.service'; import { formatMicrosoftAuthError } from './microsoft-oauth.service';
@ -50,17 +51,13 @@ export async function createImapClient(inbox: Inbox): Promise<ImapFlow> {
export async function testImap( export async function testImap(
inbox: Inbox, inbox: Inbox,
): Promise<{ ok: boolean; error?: string }> { ): Promise<{ ok: boolean; error?: string }> {
try {
await withRetry(
async () => {
const client = await createImapClient(inbox); const client = await createImapClient(inbox);
try { try {
await client.connect(); await client.connect();
await client.mailboxOpen('INBOX'); await client.mailboxOpen('INBOX');
return { ok: true };
} catch (err) {
const message = err instanceof Error ? err.message : 'IMAP connection failed';
return {
ok: false,
error: formatMicrosoftAuthError(message),
};
} finally { } finally {
try { try {
await client.logout(); await client.logout();
@ -68,12 +65,25 @@ export async function testImap(
// ignore logout errors // ignore logout errors
} }
} }
},
{ label: `imap-test:${inbox.email}` },
);
return { ok: true };
} catch (err) {
const message = err instanceof Error ? err.message : 'IMAP connection failed';
return {
ok: false,
error: formatMicrosoftAuthError(message),
};
}
} }
export async function withImapClient<T>( export async function withImapClient<T>(
inbox: Inbox, inbox: Inbox,
fn: (client: ImapFlow) => Promise<T>, fn: (client: ImapFlow) => Promise<T>,
): Promise<T> { ): Promise<T> {
return withRetry(
async () => {
const client = await createImapClient(inbox); const client = await createImapClient(inbox);
await client.connect(); await client.connect();
try { try {
@ -85,4 +95,7 @@ export async function withImapClient<T>(
// ignore logout errors // ignore logout errors
} }
} }
},
{ label: `imap:${inbox.email}` },
);
} }

View file

@ -73,6 +73,29 @@ export async function enqueueSendJob(
return job.id; return job.id;
} }
const STALE_SEND_JOB_MS = 60 * 60 * 1000;
export async function reclaimStaleSendJobs(staleMs = STALE_SEND_JOB_MS): Promise<number> {
const cutoff = new Date(Date.now() - staleMs);
const result = await prisma.sendJob.updateMany({
where: {
status: SendJobStatus.processing,
updatedAt: { lt: cutoff },
},
data: {
status: SendJobStatus.failed,
errorMessage: 'Stale processing job reclaimed',
processedAt: new Date(),
},
});
if (result.count > 0) {
logger.warn({ count: result.count }, 'Reclaimed stale send jobs stuck in processing');
}
return result.count;
}
export async function claimDueSendJob(campaignId: string) { export async function claimDueSendJob(campaignId: string) {
const now = new Date(); const now = new Date();
const job = await prisma.sendJob.findFirst({ const job = await prisma.sendJob.findFirst({

View file

@ -40,4 +40,17 @@ export async function seedDefaultSettings(): Promise<void> {
update: {}, update: {},
}); });
} }
await migrateLegacyRescuerCron();
}
/** One-time bump for installs still on the old 10-minute rescuer interval. */
async function migrateLegacyRescuerCron(): Promise<void> {
const row = await prisma.setting.findUnique({ where: { key: 'rescuer_cron' } });
if (row?.value === '*/10 * * * *') {
await prisma.setting.update({
where: { key: 'rescuer_cron' },
data: { value: DEFAULT_SETTINGS.rescuer_cron },
});
}
} }

View file

@ -0,0 +1,81 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Inbox" (
"id" TEXT NOT NULL PRIMARY KEY,
"workspaceId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"smtpHost" TEXT NOT NULL,
"smtpPort" INTEGER NOT NULL,
"imapHost" TEXT NOT NULL,
"imapPort" INTEGER NOT NULL,
"username" TEXT NOT NULL,
"password" TEXT NOT NULL,
"authType" TEXT NOT NULL DEFAULT 'password',
"oauthRefreshToken" TEXT,
"role" TEXT NOT NULL DEFAULT 'unassigned',
"provider" TEXT NOT NULL DEFAULT 'custom',
"status" TEXT NOT NULL DEFAULT 'active',
"dailyLimit" INTEGER NOT NULL DEFAULT 40,
"currentRamp" INTEGER NOT NULL DEFAULT 5,
"industry" TEXT NOT NULL DEFAULT 'SaaS',
"senderName" TEXT,
"companyName" TEXT,
"lastError" TEXT,
"errorAt" DATETIME,
"consecutiveFailures" INTEGER NOT NULL DEFAULT 0,
"spamFolderPath" TEXT,
"lastSpamRescuedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Inbox_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_Inbox" ("authType", "companyName", "createdAt", "currentRamp", "dailyLimit", "email", "errorAt", "id", "imapHost", "imapPort", "industry", "lastError", "lastSpamRescuedAt", "oauthRefreshToken", "password", "provider", "role", "senderName", "smtpHost", "smtpPort", "spamFolderPath", "status", "updatedAt", "username", "workspaceId") SELECT "authType", "companyName", "createdAt", "currentRamp", "dailyLimit", "email", "errorAt", "id", "imapHost", "imapPort", "industry", "lastError", "lastSpamRescuedAt", "oauthRefreshToken", "password", "provider", "role", "senderName", "smtpHost", "smtpPort", "spamFolderPath", "status", "updatedAt", "username", "workspaceId" FROM "Inbox";
DROP TABLE "Inbox";
ALTER TABLE "new_Inbox" RENAME TO "Inbox";
CREATE INDEX "Inbox_status_idx" ON "Inbox"("status");
CREATE INDEX "Inbox_workspaceId_role_idx" ON "Inbox"("workspaceId", "role");
CREATE UNIQUE INDEX "Inbox_workspaceId_email_key" ON "Inbox"("workspaceId", "email");
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Inbox" (
"id" TEXT NOT NULL PRIMARY KEY,
"workspaceId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"smtpHost" TEXT NOT NULL,
"smtpPort" INTEGER NOT NULL,
"imapHost" TEXT NOT NULL,
"imapPort" INTEGER NOT NULL,
"username" TEXT NOT NULL,
"password" TEXT NOT NULL,
"authType" TEXT NOT NULL DEFAULT 'password',
"oauthRefreshToken" TEXT,
"role" TEXT NOT NULL DEFAULT 'unassigned',
"provider" TEXT NOT NULL DEFAULT 'custom',
"status" TEXT NOT NULL DEFAULT 'active',
"dailyLimit" INTEGER NOT NULL DEFAULT 40,
"currentRamp" INTEGER NOT NULL DEFAULT 5,
"industry" TEXT NOT NULL DEFAULT 'SaaS',
"senderName" TEXT,
"companyName" TEXT,
"lastError" TEXT,
"errorAt" DATETIME,
"consecutiveFailures" INTEGER NOT NULL DEFAULT 0,
"spamFolderPath" TEXT,
"lastSpamRescuedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Inbox_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_Inbox" ("authType", "companyName", "createdAt", "currentRamp", "dailyLimit", "email", "errorAt", "id", "imapHost", "imapPort", "industry", "lastError", "lastSpamRescuedAt", "oauthRefreshToken", "password", "provider", "role", "senderName", "smtpHost", "smtpPort", "spamFolderPath", "status", "updatedAt", "username", "workspaceId") SELECT "authType", "companyName", "createdAt", "currentRamp", "dailyLimit", "email", "errorAt", "id", "imapHost", "imapPort", "industry", "lastError", "lastSpamRescuedAt", "oauthRefreshToken", "password", "provider", "role", "senderName", "smtpHost", "smtpPort", "spamFolderPath", "status", "updatedAt", "username", "workspaceId" FROM "Inbox";
DROP TABLE "Inbox";
ALTER TABLE "new_Inbox" RENAME TO "Inbox";
CREATE INDEX "Inbox_status_idx" ON "Inbox"("status");
CREATE INDEX "Inbox_workspaceId_role_idx" ON "Inbox"("workspaceId", "role");
CREATE UNIQUE INDEX "Inbox_workspaceId_email_key" ON "Inbox"("workspaceId", "email");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- Reduce IMAP polling frequency for existing installs still on the old default.
UPDATE "Setting" SET "value" = '*/20 * * * *' WHERE "key" = 'rescuer_cron' AND "value" = '*/10 * * * *';

View file

@ -121,6 +121,7 @@ model Inbox {
companyName String? companyName String?
lastError String? lastError String?
errorAt DateTime? errorAt DateTime?
consecutiveFailures Int @default(0)
spamFolderPath String? spamFolderPath String?
lastSpamRescuedAt DateTime? lastSpamRescuedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())