From 2c1c7a082c9bbbefe7f24c8b1ba2e2dac3cf383f Mon Sep 17 00:00:00 2001 From: kaizerCodes <114164516+kaizercodes@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:27:01 +0100 Subject: [PATCH 1/2] fix: Retry failed email sends with capped backoff (#1176) --- src/lib/email/queue.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/lib/email/queue.ts b/src/lib/email/queue.ts index 92856a80..f76cade8 100644 --- a/src/lib/email/queue.ts +++ b/src/lib/email/queue.ts @@ -12,6 +12,12 @@ const DEFAULT_OPTIONS: QueueOptions = { maxConcurrent: 2, }; +/** + * Upper bound for exponential backoff between retries, so a stuck queue + * doesn't stall with ever-growing delays. + */ +const DEFAULT_MAX_RETRY_DELAY_MS = 30_000; + /** * How long a completed send is remembered for dedupe purposes. * @@ -158,7 +164,11 @@ export class EmailQueue { } if (job.attempts < this.options.maxRetries) { - await delay(this.options.retryDelayMs * job.attempts); + const backoff = Math.min( + this.options.retryDelayMs * 2 ** (job.attempts - 1), + DEFAULT_MAX_RETRY_DELAY_MS, + ); + await delay(backoff); } } From 35ac1ee05e3e95a58845e6f10b9ac46c8332bc4c Mon Sep 17 00:00:00 2001 From: kaizerCodes <114164516+kaizercodes@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:27:03 +0100 Subject: [PATCH 2/2] fix: Retry failed email sends with capped backoff (#1176) --- src/lib/email/provider.ts | 44 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/lib/email/provider.ts b/src/lib/email/provider.ts index 4a62fde7..ffb6d2d2 100644 --- a/src/lib/email/provider.ts +++ b/src/lib/email/provider.ts @@ -3,6 +3,10 @@ import { EmailMessage, EmailProvider, EmailProviderType, EmailSendResult } from const DEFAULT_FROM_EMAIL = process.env.EMAIL_FROM_ADDRESS ?? 'no-reply@teachlink.com'; const DEFAULT_FROM_NAME = process.env.EMAIL_FROM_NAME ?? 'TeachLink'; +const MAX_RETRIES = 3; +const BASE_BACKOFF_MS = 500; +const MAX_BACKOFF_MS = 5000; + function asArray(value: T | T[]): T[] { return Array.isArray(value) ? value : [value]; } @@ -11,6 +15,22 @@ function resolveFrom(message: EmailMessage) { return message.from ?? { email: DEFAULT_FROM_EMAIL, name: DEFAULT_FROM_NAME }; } +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isRetryableResult(result: EmailSendResult): boolean { + if (result.success) return false; + if (result.error?.includes('SENDGRID_API_KEY is not configured')) return false; + const match = /SendGrid error (\\d+)/.exec(result.error ?? ''); + if (match) { + const status = parseInt(match[1]); + return status === 408 || status === 429 || status >= 500; + } + // Network errors and other transient exceptions should be retried. + return true; +} + class SendGridProvider implements EmailProvider { readonly type: EmailProviderType = 'sendgrid'; @@ -20,6 +40,30 @@ class SendGridProvider implements EmailProvider { return { success: false, provider: this.type, error: 'SENDGRID_API_KEY is not configured' }; } + let lastResult: EmailSendResult = { + success: false, + provider: this.type, + error: 'Email send failed after retries', + }; + let delay = BASE_BACKOFF_MS; + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + lastResult = await this.doSend(message, apiKey); + + if (lastResult.success || !isRetryableResult(lastResult)) { + return lastResult; + } + + if (attempt < MAX_RETRIES) { + await wait(delay); + delay = Math.min(delay * 2, MAX_BACKOFF_MS); + } + } + + return lastResult; + } + + private async doSend(message: EmailMessage, apiKey: string): Promise { try { const response = await fetch('https://api.sendgrid.com/v3/mail/send', { method: 'POST',