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',
lastError: 'lastError',
errorAt: 'errorAt',
consecutiveFailures: 'consecutiveFailures',
spamFolderPath: 'spamFolderPath',
lastSpamRescuedAt: 'lastSpamRescuedAt',
createdAt: 'createdAt',

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,10 @@
import type { Inbox } from '../lib/prisma';
import { InboxStatus } 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>(
inbox: Inbox,
@ -9,24 +13,49 @@ export async function withInboxGuard<T>(
try {
const result = await fn();
if (inbox.status === InboxStatus.error) {
await prisma.inbox.update({
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;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
await prisma.inbox.update({
const updated = await prisma.inbox.update({
where: { id: inbox.id },
data: {
status: InboxStatus.error,
consecutiveFailures: { increment: 1 },
lastError: message,
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;
}
}

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 = {
dispatcher_cron: '*/15 * * * *',
rescuer_cron: '*/10 * * * *',
rescuer_cron: '*/20 * * * *',
ramp_increment: '2',
industries: JSON.stringify(DEFAULT_INDUSTRIES),
} as const;

View file

@ -170,6 +170,7 @@ router.post('/', async (req: Request, res: Response, next: NextFunction) => {
companyName: data.companyName ?? null,
lastError: null,
errorAt: null,
consecutiveFailures: 0,
spamFolderPath: null,
lastSpamRescuedAt: null,
createdAt: new Date(),
@ -300,7 +301,7 @@ router.patch('/:id', async (req: Request, res: Response, next: NextFunction) =>
...(password ? { password: encrypt(password) } : {}),
...(oauthRefreshToken ? { oauthRefreshToken: encrypt(oauthRefreshToken) } : {}),
...(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(),
},
});
} else if (inbox.status === InboxStatus.error) {
} else {
await prisma.inbox.update({
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 type { Inbox } from '../lib/prisma';
import { MailboxAuthType } from '../lib/prisma';
import { withRetry } from '../lib/retry';
import { decrypt } from './crypto.service';
import { EmailPayload } from '../models/types';
import { resolveMailboxAccessToken } from './mailbox-auth.service';
@ -58,9 +59,18 @@ export async function createSmtpTransport(inbox: Inbox): Promise<Transporter> {
export async function testSmtp(
inbox: Inbox,
): Promise<{ ok: boolean; error?: string }> {
try {
await withRetry(
async () => {
const transporter = await createSmtpTransport(inbox);
try {
await transporter.verify();
} finally {
transporter.close();
}
},
{ label: `smtp-test:${inbox.email}` },
);
return { ok: true };
} catch (err) {
const message = err instanceof Error ? err.message : 'SMTP verification failed';
@ -68,8 +78,6 @@ export async function testSmtp(
ok: false,
error: formatMicrosoftAuthError(message),
};
} finally {
transporter.close();
}
}
@ -77,6 +85,8 @@ export async function sendMail(
inbox: Inbox,
payload: EmailPayload,
): Promise<{ messageId: string }> {
return withRetry(
async () => {
const transporter = await createSmtpTransport(inbox);
try {
@ -94,6 +104,9 @@ export async function sendMail(
} finally {
transporter.close();
}
},
{ label: `smtp-send:${inbox.email}` },
);
}
export async function testConnection(

View file

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

View file

@ -73,6 +73,29 @@ export async function enqueueSendJob(
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) {
const now = new Date();
const job = await prisma.sendJob.findFirst({

View file

@ -40,4 +40,17 @@ export async function seedDefaultSettings(): Promise<void> {
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?
lastError String?
errorAt DateTime?
consecutiveFailures Int @default(0)
spamFolderPath String?
lastSpamRescuedAt DateTime?
createdAt DateTime @default(now())