try-catch the email-queue step bulldozer-dependent item checks

This commit is contained in:
Bilal Godil 2026-07-01 13:18:35 -07:00
parent f7ac51b51a
commit fef19c6b0f

View File

@ -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 = {