11import * as nodemailer from 'nodemailer'
22import { createLogger } from '@certified-app/shared'
3- import type { Transporter } from 'nodemailer'
3+ import type { SendMailOptions , SentMessageInfo , Transporter } from 'nodemailer'
44import type { EmailConfig } from '@certified-app/shared'
55import {
66 buildSignInCodeEmail ,
@@ -11,6 +11,11 @@ import { buildClientBrandedEmail } from './client-template.js'
1111
1212const logger = createLogger ( 'auth:email' )
1313
14+ // Exposed for tests that assert the structured fields on the per-send
15+ // completion line (elapsedMs / messageId / smtpResponse). Production
16+ // code must not import this.
17+ export const _loggerForTest : ReturnType < typeof createLogger > = logger
18+
1419// Re-exports so existing tests that reach for the template cache still
1520// resolve through sender.js. New code should import directly from
1621// ./client-template.js.
@@ -95,6 +100,47 @@ export class EmailSender {
95100 }
96101 }
97102
103+ /**
104+ * Send one message, measuring how long the SMTP handoff takes and
105+ * folding the elapsed time plus the provider's `messageId` / server
106+ * `response` into a single structured completion line. `messageId`
107+ * lets these app logs be joined to Resend's per-message delivery
108+ * events (see #183 / #198) so provider-side latency can be told apart
109+ * from users submitting stale codes.
110+ *
111+ * On failure the error is re-thrown after stamping it with
112+ * `elapsedMs`, so the caller's existing error log can carry the
113+ * duration without adding a second line per failure.
114+ */
115+ private async timedSendMail (
116+ mail : SendMailOptions ,
117+ message : string ,
118+ extra : Record < string , unknown > = { } ,
119+ ) : Promise < void > {
120+ const startedAt = performance . now ( )
121+ try {
122+ const info : SentMessageInfo = await this . transporter . sendMail ( mail )
123+ const elapsedMs = Math . round ( performance . now ( ) - startedAt )
124+ logger . info (
125+ {
126+ email : mail . to ,
127+ ...extra ,
128+ elapsedMs,
129+ messageId : info . messageId ,
130+ smtpResponse : info . response ,
131+ } ,
132+ message ,
133+ )
134+ } catch ( err ) {
135+ const elapsedMs = Math . round ( performance . now ( ) - startedAt )
136+ if ( err instanceof Error ) {
137+ // Stamp the duration so the caller's error log carries it.
138+ ; ( err as Error & { elapsedMs ?: number } ) . elapsedMs = elapsedMs
139+ }
140+ throw err
141+ }
142+ }
143+
98144 async sendOtpCode ( opts : {
99145 to : string
100146 code : string
@@ -125,16 +171,16 @@ export class EmailSender {
125171 trustedClients : this . trustedClients ,
126172 } )
127173 if ( branded ) {
128- await this . transporter . sendMail ( {
129- from : `"${ branded . fromName } " <${ this . config . from } >` ,
130- to,
131- subject : branded . subject ,
132- text : branded . text ,
133- html : branded . html ,
134- } )
135- logger . info (
136- { email : to , clientId : opts . clientId } ,
174+ await this . timedSendMail (
175+ {
176+ from : `"${ branded . fromName } " <${ this . config . from } >` ,
177+ to,
178+ subject : branded . subject ,
179+ text : branded . text ,
180+ html : branded . html ,
181+ } ,
137182 'Sent client-branded OTP email' ,
183+ { clientId : opts . clientId } ,
138184 )
139185 return
140186 }
@@ -158,14 +204,16 @@ export class EmailSender {
158204 const { to, ...rest } = opts
159205 const { subject, text, html } = buildSignInCodeEmail ( rest )
160206
161- await this . transporter . sendMail ( {
162- from : `"${ this . config . fromName } " <${ this . config . from } >` ,
163- to,
164- subject,
165- text,
166- html,
167- } )
168- logger . info ( { email : to } , 'Sent sign-in OTP email' )
207+ await this . timedSendMail (
208+ {
209+ from : `"${ this . config . fromName } " <${ this . config . from } >` ,
210+ to,
211+ subject,
212+ text,
213+ html,
214+ } ,
215+ 'Sent sign-in OTP email' ,
216+ )
169217 }
170218
171219 private async sendWelcomeCode ( opts : {
@@ -177,14 +225,16 @@ export class EmailSender {
177225 const { to, ...rest } = opts
178226 const { subject, text, html } = buildWelcomeCodeEmail ( rest )
179227
180- await this . transporter . sendMail ( {
181- from : `"${ this . config . fromName } " <${ this . config . from } >` ,
182- to,
183- subject,
184- text,
185- html,
186- } )
187- logger . info ( { email : to } , 'Sent welcome OTP email' )
228+ await this . timedSendMail (
229+ {
230+ from : `"${ this . config . fromName } " <${ this . config . from } >` ,
231+ to,
232+ subject,
233+ text,
234+ html,
235+ } ,
236+ 'Sent welcome OTP email' ,
237+ )
188238 }
189239
190240 async sendBackupEmailVerification ( opts : {
@@ -196,13 +246,15 @@ export class EmailSender {
196246 const { to, ...rest } = opts
197247 const { subject, text, html } = buildBackupEmailVerificationEmail ( rest )
198248
199- await this . transporter . sendMail ( {
200- from : `"${ this . config . fromName } " <${ this . config . from } >` ,
201- to,
202- subject,
203- text,
204- html,
205- } )
206- logger . info ( { email : to } , 'Sent backup email verification' )
249+ await this . timedSendMail (
250+ {
251+ from : `"${ this . config . fromName } " <${ this . config . from } >` ,
252+ to,
253+ subject,
254+ text,
255+ html,
256+ } ,
257+ 'Sent backup email verification' ,
258+ )
207259 }
208260}
0 commit comments