Skip to content

Commit c0b1eb1

Browse files
authored
Merge pull request #203 from hypercerts-org/feat/otp-send-timing
feat(auth-service): log OTP send duration, messageId and SMTP response
2 parents 78d9769 + 2193515 commit c0b1eb1

4 files changed

Lines changed: 202 additions & 36 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'ePDS': minor
3+
---
4+
5+
OTP send logs now record how long the email handoff took, the provider's message ID, and the SMTP server response.
6+
7+
**Affects:** Operators
8+
9+
**Operators:** every OTP email completion line (all four paths — client-branded, sign-in, welcome, backup-email verification) now carries `elapsedMs`, `messageId`, and `smtpResponse` alongside `email`. `messageId` lets these logs be joined to Resend delivery events; `elapsedMs` isolates a slow SMTP handoff from other causes of late-arriving codes. Failed sends log `elapsedMs` too, on the existing `better-auth: failed to send OTP email` error line.

packages/auth-service/src/__tests__/email-template.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
EmailSender,
1414
_seedTemplateCacheForTest,
1515
_clearTemplateCacheForTest,
16+
_loggerForTest,
1617
} from '../email/sender.js'
1718
import {
1819
formatOtpHtmlGrouped,
@@ -267,4 +268,102 @@ describe('EmailSender', () => {
267268
).resolves.toBeUndefined()
268269
})
269270
})
271+
272+
// Regression cover for #183: the per-send completion line must carry
273+
// the SMTP handoff duration and the provider's messageId / server
274+
// response so app logs can be timed and joined to Resend delivery
275+
// events. jsonTransport supplies a real messageId + response.
276+
describe('per-send diagnostic logging', () => {
277+
it('folds elapsedMs, messageId and smtpResponse into the success line', async () => {
278+
const sender = makeSender()
279+
const info = vi
280+
.spyOn(_loggerForTest, 'info')
281+
.mockImplementation(() => _loggerForTest)
282+
283+
await sender.sendOtpCode({
284+
to: 'user@test.com',
285+
code: '12345678',
286+
clientAppName: 'Test App',
287+
pdsName: 'Test PDS',
288+
pdsDomain: 'pds.example',
289+
})
290+
291+
expect(info).toHaveBeenCalledOnce()
292+
const [fields, message] = info.mock.calls[0] as [
293+
Record<string, unknown>,
294+
string,
295+
]
296+
expect(message).toBe('Sent sign-in OTP email')
297+
expect(fields.email).toBe('user@test.com')
298+
expect(fields.elapsedMs).toEqual(expect.any(Number))
299+
expect(fields.elapsedMs as number).toBeGreaterThanOrEqual(0)
300+
expect(fields.messageId).toEqual(expect.any(String))
301+
// jsonTransport reports its serialized envelope as the response.
302+
expect(fields).toHaveProperty('smtpResponse')
303+
304+
info.mockRestore()
305+
})
306+
307+
it('carries clientId alongside the diagnostic fields on the branded line', async () => {
308+
const TRUSTED_ID = 'https://branded.app/client-metadata.json'
309+
_seedClientMetadataCacheForTest(TRUSTED_ID, {
310+
client_name: 'Branded App',
311+
email_template_uri: 'https://branded.app/email-template.html',
312+
logo_uri: 'https://branded.app/logo.png',
313+
})
314+
_seedTemplateCacheForTest(
315+
'https://branded.app/email-template.html',
316+
'<html><body>Your code is {{code}}</body></html>',
317+
)
318+
const sender = makeSender([TRUSTED_ID])
319+
const info = vi
320+
.spyOn(_loggerForTest, 'info')
321+
.mockImplementation(() => _loggerForTest)
322+
323+
await sender.sendOtpCode({
324+
to: 'branded@test.com',
325+
code: '99999999',
326+
clientAppName: 'Fallback Name',
327+
clientId: TRUSTED_ID,
328+
pdsName: 'Test PDS',
329+
pdsDomain: 'pds.example',
330+
})
331+
332+
const [fields, message] = info.mock.calls[0] as [
333+
Record<string, unknown>,
334+
string,
335+
]
336+
expect(message).toBe('Sent client-branded OTP email')
337+
expect(fields.clientId).toBe(TRUSTED_ID)
338+
expect(fields.elapsedMs).toEqual(expect.any(Number))
339+
expect(fields.messageId).toEqual(expect.any(String))
340+
341+
info.mockRestore()
342+
})
343+
344+
it('stamps elapsedMs onto a failed send for the caller error log', async () => {
345+
const sender = makeSender()
346+
vi.spyOn(sender['transporter'], 'sendMail').mockRejectedValue(
347+
new Error('SMTP handshake timed out'),
348+
)
349+
350+
let caught: (Error & { elapsedMs?: number }) | undefined
351+
try {
352+
await sender.sendOtpCode({
353+
to: 'user@test.com',
354+
code: '12345678',
355+
clientAppName: 'Test App',
356+
pdsName: 'Test PDS',
357+
pdsDomain: 'pds.example',
358+
})
359+
} catch (e) {
360+
caught = e as Error & { elapsedMs?: number }
361+
}
362+
363+
expect(caught).toBeInstanceOf(Error)
364+
expect(caught?.message).toBe('SMTP handshake timed out')
365+
expect(caught?.elapsedMs).toEqual(expect.any(Number))
366+
expect(caught?.elapsedMs as number).toBeGreaterThanOrEqual(0)
367+
})
368+
})
270369
})

packages/auth-service/src/better-auth.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -362,9 +362,15 @@ export function createBetterAuth(
362362
isNewUser,
363363
})
364364
.catch((err: unknown) => {
365-
// Log and swallow — caller does not await this
365+
// Log and swallow — caller does not await this.
366+
// `EmailSender.timedSendMail` stamps the SMTP handoff
367+
// duration onto the error so this single line carries it.
368+
const elapsedMs =
369+
err instanceof Error
370+
? (err as Error & { elapsedMs?: number }).elapsedMs
371+
: undefined
366372
logger.error(
367-
{ err, email, isNewUser },
373+
{ err, email, isNewUser, elapsedMs },
368374
'better-auth: failed to send OTP email',
369375
)
370376
})

packages/auth-service/src/email/sender.ts

Lines changed: 86 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as nodemailer from 'nodemailer'
22
import { createLogger } from '@certified-app/shared'
3-
import type { Transporter } from 'nodemailer'
3+
import type { SendMailOptions, SentMessageInfo, Transporter } from 'nodemailer'
44
import type { EmailConfig } from '@certified-app/shared'
55
import {
66
buildSignInCodeEmail,
@@ -11,6 +11,11 @@ import { buildClientBrandedEmail } from './client-template.js'
1111

1212
const 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

Comments
 (0)