Skip to content

Commit ba695cd

Browse files
authored
Retry sending emails on transient errors (#1309)
* Retry sending emails on transient errors * Code style * Fixes
1 parent b959e8d commit ba695cd

2 files changed

Lines changed: 109 additions & 18 deletions

File tree

backend/bin/kafkaConsumer/common/EmailTemplater/sendEmailTemplate.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,20 +37,24 @@ export async function sendEmailTemplateToUser({
3737
: undefined
3838

3939
if (context.test) {
40-
;(context.logger?.info || console.log)(
40+
const logger = context.logger ?? console
41+
logger.info(
4142
`To: ${
4243
email ?? user.email
4344
}\nSubject: ${title}\nText: ${text}\nHtml: ${html_body}`,
4445
)
4546

4647
return
4748
}
48-
await sendMail({
49-
to: email ?? user.email,
50-
subject: emptyOrNullToUndefined(title),
51-
text,
52-
html: emptyOrNullToUndefined(html_body),
53-
})
49+
await sendMail(
50+
{
51+
to: email ?? user.email,
52+
subject: emptyOrNullToUndefined(title),
53+
text,
54+
html: emptyOrNullToUndefined(html_body),
55+
},
56+
{ logger: context.logger },
57+
)
5458
}
5559

5660
interface ApplyTemplateArgs {

backend/util/sendMail.ts

Lines changed: 98 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ export async function sendMail(
3131
{ to, text, subject, html }: SendMailOptions,
3232
context?: SendMailContext,
3333
) {
34-
const { logger } = context ?? {}
34+
const logger = context?.logger
35+
const logInfo = logger?.info.bind(logger) ?? console.log
3536

3637
const options: SMTPTransport.Options = {
3738
host: SMTP_HOST,
@@ -41,17 +42,103 @@ export async function sendMail(
4142
user: SMTP_USER, // generated ethereal user
4243
pass: SMTP_PASS, // generated ethereal password
4344
},
45+
connectionTimeout: 15000,
46+
greetingTimeout: 15000,
47+
socketTimeout: 30000,
4448
}
4549

46-
const transporter = createTransport(options)
47-
48-
const info = await transporter.sendMail({
49-
from: SMTP_FROM, // sender address
50-
to, // list of receivers
51-
subject, // Subject line
52-
text, // plain text body
53-
html, // html body
50+
await withRetries({
51+
maxRetries: 3,
52+
logInfo,
53+
operationName: "SMTP send",
54+
isTransientError: isTransientSmtpError,
55+
operation: async (attempt) => {
56+
const transporter = createTransport(options)
57+
try {
58+
const info = await transporter.sendMail({
59+
from: SMTP_FROM, // sender address
60+
to, // list of receivers
61+
subject, // Subject line
62+
text, // plain text body
63+
html, // html body
64+
})
65+
logInfo(
66+
`Message sent: ${info.messageId}${
67+
attempt > 1 ? ` (succeeded on attempt ${attempt})` : ""
68+
}`,
69+
)
70+
} finally {
71+
transporter.close()
72+
}
73+
},
5474
})
55-
;(logger?.info ?? console.log)(`Message sent: ${info.messageId}`)
56-
// Message sent: <[email protected]>
75+
}
76+
77+
interface RetryOptions {
78+
maxRetries: number
79+
logInfo?: (message: string) => void
80+
operationName: string
81+
operation: (attempt: number) => Promise<void>
82+
isTransientError: (error: unknown) => boolean
83+
}
84+
85+
async function withRetries({
86+
maxRetries,
87+
logInfo,
88+
operationName,
89+
operation,
90+
isTransientError,
91+
}: RetryOptions) {
92+
const BASE_DELAY_MS = 1000
93+
const jitter = () => Math.floor(Math.random() * 250)
94+
const log = logInfo ?? ((message: string) => console.log(message))
95+
96+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
97+
try {
98+
await operation(attempt)
99+
return
100+
} catch (error: any) {
101+
if (attempt >= maxRetries || !isTransientError(error)) {
102+
throw error
103+
}
104+
log(
105+
`${operationName} failed (attempt ${attempt}/${maxRetries}, retrying): ${
106+
error?.message ?? error
107+
}`,
108+
)
109+
const delay =
110+
Math.min(BASE_DELAY_MS * 2 ** (attempt - 1), 8000) + jitter()
111+
await new Promise((resolve) => setTimeout(resolve, delay))
112+
}
113+
}
114+
}
115+
116+
function isTransientSmtpError(error: unknown) {
117+
interface SmtpError {
118+
responseCode?: number
119+
response?: string
120+
message?: string
121+
code?: string
122+
}
123+
const smtpError = error as SmtpError
124+
const responseCode = Number(smtpError?.responseCode)
125+
if (responseCode >= 400 && responseCode < 500) {
126+
return true
127+
}
128+
const response = String(smtpError?.response ?? "")
129+
if (/^4\d\d/.test(response)) {
130+
return true
131+
}
132+
const message = String(smtpError?.message ?? "")
133+
if (/\b4\d\d/.test(message)) {
134+
return true
135+
}
136+
const code = String(smtpError?.code ?? "")
137+
return [
138+
"ECONNRESET",
139+
"ETIMEDOUT",
140+
"ESOCKET",
141+
"EPIPE",
142+
"ECONNREFUSED",
143+
].includes(code)
57144
}

0 commit comments

Comments
 (0)