@@ -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 ( / \b 4 \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