diff --git a/apps/backend/src/lib/email-queue-step.tsx b/apps/backend/src/lib/email-queue-step.tsx index 2961ed2e4..c177c4d7b 100644 --- a/apps/backend/src/lib/email-queue-step.tsx +++ b/apps/backend/src/lib/email-queue-step.tsx @@ -708,44 +708,55 @@ async function processSingleEmail(context: TenancyProcessingContext, row: EmailO } if (context.billingTeamId != null && row.sendRetries === 0 && arePlanLimitsEnforced()) { - const app = getHexclaveServerApp(); - const emailItem = await app.getItem({ itemId: ITEM_IDS.emailsPerMonth, teamId: context.billingTeamId }); - const isDebited = await emailItem.tryDecreaseQuantity(1); - if (!isDebited) { - const errorMessage = "Monthly email sending limit exceeded for your plan. Please upgrade your plan or wait until next month."; - // Intentionally do NOT increment sendRetries or append to - // sendAttemptErrors. sendRetries tracks SMTP attempts, and a quota - // rejection never reaches SMTP; the DB-level - // EmailOutbox_sendAttemptErrors_requires_failure CHECK also forbids - // non-null sendAttemptErrors while sendRetries is 0. All the - // debugging info is captured in sendServerError* below. Keeping - // sendRetries at 0 means that if a future admin flow ever - // un-finalises this row (clearing finishedSendingAt), the - // `sendRetries === 0` quota gate above re-fires instead of being - // silently skipped. - await globalPrismaClient.emailOutbox.update({ - where: { - tenancyId_id: { - tenancyId: row.tenancyId, - id: row.id, + // The monthly-email quota lives in bulldozer, but email delivery must not + // hard-depend on it: if bulldozer is unreachable, report it and send the + // email anyway (treat the quota as available) rather than failing + // transactional mail such as password resets, sign-in codes, and team + // invites. The quota is re-enforced on the next send once bulldozer is + // back. A DB error while recording a quota rejection also lands here and + // fails open, which is the same safe direction. + try { + const app = getHexclaveServerApp(); + const emailItem = await app.getItem({ itemId: ITEM_IDS.emailsPerMonth, teamId: context.billingTeamId }); + const isDebited = await emailItem.tryDecreaseQuantity(1); + if (!isDebited) { + const errorMessage = "Monthly email sending limit exceeded for your plan. Please upgrade your plan or wait until next month."; + // Intentionally do NOT increment sendRetries or append to + // sendAttemptErrors. sendRetries tracks SMTP attempts, and a quota + // rejection never reaches SMTP; the DB-level + // EmailOutbox_sendAttemptErrors_requires_failure CHECK also forbids + // non-null sendAttemptErrors while sendRetries is 0. All the + // debugging info is captured in sendServerError* below. Keeping + // sendRetries at 0 means that if a future admin flow ever + // un-finalises this row (clearing finishedSendingAt), the + // `sendRetries === 0` quota gate above re-fires instead of being + // silently skipped. + await globalPrismaClient.emailOutbox.update({ + where: { + tenancyId_id: { + tenancyId: row.tenancyId, + id: row.id, + }, + finishedSendingAt: null, }, - finishedSendingAt: null, - }, - data: { - finishedSendingAt: new Date(), - canHaveDeliveryInfo: false, - sendServerErrorExternalMessage: errorMessage, - sendServerErrorExternalDetails: { errorType: "monthly-email-limit-exceeded" }, - sendServerErrorInternalMessage: errorMessage, - sendServerErrorInternalDetails: { - errorType: "monthly-email-limit-exceeded", - remainingQuota: emailItem.quantity, - billingTeamId: context.billingTeamId, + data: { + finishedSendingAt: new Date(), + canHaveDeliveryInfo: false, + sendServerErrorExternalMessage: errorMessage, + sendServerErrorExternalDetails: { errorType: "monthly-email-limit-exceeded" }, + sendServerErrorInternalMessage: errorMessage, + sendServerErrorInternalDetails: { + errorType: "monthly-email-limit-exceeded", + remainingQuota: emailItem.quantity, + billingTeamId: context.billingTeamId, + }, + shouldUpdateSequenceId: true, }, - shouldUpdateSequenceId: true, - }, - }); - return; + }); + return; + } + } catch (error) { + captureError("email-queue-step:monthly-email-quota-check", error); } } const baseContent = {