From c8f602f3fba246aa1cc63dd0e533d8439548b814 Mon Sep 17 00:00:00 2001 From: Henrik Nygren Date: Fri, 6 Feb 2026 09:20:24 +0200 Subject: [PATCH 1/3] Retry sending emails on transient errors --- .../EmailTemplater/sendEmailTemplate.ts | 18 +-- backend/util/sendMail.ts | 114 ++++++++++++++++-- 2 files changed, 114 insertions(+), 18 deletions(-) diff --git a/backend/bin/kafkaConsumer/common/EmailTemplater/sendEmailTemplate.ts b/backend/bin/kafkaConsumer/common/EmailTemplater/sendEmailTemplate.ts index 331ec32d8..4beeeb6ba 100644 --- a/backend/bin/kafkaConsumer/common/EmailTemplater/sendEmailTemplate.ts +++ b/backend/bin/kafkaConsumer/common/EmailTemplater/sendEmailTemplate.ts @@ -37,7 +37,8 @@ export async function sendEmailTemplateToUser({ : undefined if (context.test) { - ;(context.logger?.info || console.log)( + const logger = context.logger ?? console + logger.info( `To: ${ email ?? user.email }\nSubject: ${title}\nText: ${text}\nHtml: ${html_body}`, @@ -45,12 +46,15 @@ export async function sendEmailTemplateToUser({ return } - await sendMail({ - to: email ?? user.email, - subject: emptyOrNullToUndefined(title), - text, - html: emptyOrNullToUndefined(html_body), - }) + await sendMail( + { + to: email ?? user.email, + subject: emptyOrNullToUndefined(title), + text, + html: emptyOrNullToUndefined(html_body), + }, + { logger: context.logger }, + ) } interface ApplyTemplateArgs { diff --git a/backend/util/sendMail.ts b/backend/util/sendMail.ts index a2bb9ba14..5e943e055 100644 --- a/backend/util/sendMail.ts +++ b/backend/util/sendMail.ts @@ -31,7 +31,8 @@ export async function sendMail( { to, text, subject, html }: SendMailOptions, context?: SendMailContext, ) { - const { logger } = context ?? {} + const logger = context?.logger ?? console + const logInfo = logger.info.bind(logger) const options: SMTPTransport.Options = { host: SMTP_HOST, @@ -41,17 +42,108 @@ export async function sendMail( user: SMTP_USER, // generated ethereal user pass: SMTP_PASS, // generated ethereal password }, + pool: false, // Disable pooling for better retry isolation + connectionTimeout: 15000, + greetingTimeout: 15000, + socketTimeout: 30000, } - const transporter = createTransport(options) - - const info = await transporter.sendMail({ - from: SMTP_FROM, // sender address - to, // list of receivers - subject, // Subject line - text, // plain text body - html, // html body + await withRetries({ + maxRetries: 3, + logger, + logInfo, + operationName: "SMTP send", + isTransientError: isTransientSmtpError, + operation: async (attempt) => { + const transporter = createTransport(options) + try { + const info = await transporter.sendMail({ + from: SMTP_FROM, // sender address + to, // list of receivers + subject, // Subject line + text, // plain text body + html, // html body + }) + logInfo( + `Message sent: ${info.messageId}${ + attempt > 1 ? ` (succeeded on attempt ${attempt})` : "" + }`, + ) + } finally { + transporter.close() + } + }, }) - ;(logger?.info ?? console.log)(`Message sent: ${info.messageId}`) - // Message sent: +} + +interface RetryOptions { + maxRetries: number + logger?: winston.Logger + logInfo?: (message: string) => void + operationName: string + operation: (attempt: number) => Promise + isTransientError: (error: unknown) => boolean +} + +async function withRetries({ + maxRetries, + logger, + logInfo, + operationName, + operation, + isTransientError, +}: RetryOptions) { + const BASE_DELAY_MS = 1000 + const jitter = () => Math.floor(Math.random() * 250) + const log = + logInfo ?? ((message: string) => console.log(message)) + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + await operation(attempt) + return + } catch (error: any) { + if (attempt >= maxRetries || !isTransientError(error)) { + throw error + } + log( + `${operationName} failed (attempt ${attempt}/${maxRetries}, retrying): ${ + error?.message ?? error + }`, + ) + const delay = + Math.min(BASE_DELAY_MS * 2 ** (attempt - 1), 8000) + jitter() + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } +} + +function isTransientSmtpError(error: unknown) { + interface SmtpError { + responseCode?: number + response?: string + message?: string + code?: string + } + const smtpError = error as SmtpError + const responseCode = Number(smtpError?.responseCode) + if (responseCode >= 400 && responseCode < 500) { + return true + } + const response = String(smtpError?.response ?? "") + if (/^4\d\d/.test(response)) { + return true + } + const message = String(smtpError?.message ?? "") + if (/\b4\d\d/.test(message)) { + return true + } + const code = String(smtpError?.code ?? "") + return [ + "ECONNRESET", + "ETIMEDOUT", + "ESOCKET", + "EPIPE", + "ECONNREFUSED", + ].includes(code) } From 4f835b187a1e47227102e6cef42572b6e8dda0c3 Mon Sep 17 00:00:00 2001 From: Henrik Nygren Date: Fri, 6 Feb 2026 09:27:49 +0200 Subject: [PATCH 2/3] Code style --- backend/util/sendMail.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/util/sendMail.ts b/backend/util/sendMail.ts index 5e943e055..53c00b9d1 100644 --- a/backend/util/sendMail.ts +++ b/backend/util/sendMail.ts @@ -95,8 +95,7 @@ async function withRetries({ }: RetryOptions) { const BASE_DELAY_MS = 1000 const jitter = () => Math.floor(Math.random() * 250) - const log = - logInfo ?? ((message: string) => console.log(message)) + const log = logInfo ?? ((message: string) => console.log(message)) for (let attempt = 1; attempt <= maxRetries; attempt++) { try { From 478169fedebe5ed2ddad958f223cd30467e9119d Mon Sep 17 00:00:00 2001 From: Henrik Nygren Date: Fri, 6 Feb 2026 09:35:56 +0200 Subject: [PATCH 3/3] Fixes --- backend/util/sendMail.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/backend/util/sendMail.ts b/backend/util/sendMail.ts index 53c00b9d1..463a9fb6f 100644 --- a/backend/util/sendMail.ts +++ b/backend/util/sendMail.ts @@ -31,8 +31,8 @@ export async function sendMail( { to, text, subject, html }: SendMailOptions, context?: SendMailContext, ) { - const logger = context?.logger ?? console - const logInfo = logger.info.bind(logger) + const logger = context?.logger + const logInfo = logger?.info.bind(logger) ?? console.log const options: SMTPTransport.Options = { host: SMTP_HOST, @@ -42,7 +42,6 @@ export async function sendMail( user: SMTP_USER, // generated ethereal user pass: SMTP_PASS, // generated ethereal password }, - pool: false, // Disable pooling for better retry isolation connectionTimeout: 15000, greetingTimeout: 15000, socketTimeout: 30000, @@ -50,7 +49,6 @@ export async function sendMail( await withRetries({ maxRetries: 3, - logger, logInfo, operationName: "SMTP send", isTransientError: isTransientSmtpError, @@ -78,7 +76,6 @@ export async function sendMail( interface RetryOptions { maxRetries: number - logger?: winston.Logger logInfo?: (message: string) => void operationName: string operation: (attempt: number) => Promise @@ -87,7 +84,6 @@ interface RetryOptions { async function withRetries({ maxRetries, - logger, logInfo, operationName, operation,