bilhej/frontend/e2e/helpers/mailpit.ts
Joakim Mörling 86fb946e33
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m2s
CI / E2E browser tests (push) Successful in 1m55s
Add password reset, logged-in change password, and Mailpit email dev/E2E.
Operators can fix prod admin passwords without email via Byt lösenord;
end users can use forgot-password when SMTP is configured. Local and CI
use Mailpit to capture outbound mail and verify reset links end-to-end.

- Backend: V8 password_reset_tokens, PasswordResetService, EmailService,
  POST /api/auth/forgot-password, reset-password, change-password
- Optional testToken in forgot-password response (docker profile only, for E2E)
- Frontend: ForgotPasswordPage, ResetPasswordPage, ChangePasswordPage,
  routes, login link, header Byt lösenord
- Mailpit (ghcr.io/axllent/mailpit:v1.28) in docker-compose + e2e stack
- E2E: password-reset.spec.ts + Mailpit API helper tests SMTP delivery
- Separate dev/e2e Docker image names to avoid overwriting bilhej-frontend
- Docs: README email section, production-email-checklist, .env.example
- Unit/integration tests for reset, change password, and Vitest page specs

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 18:05:15 +02:00

105 lines
3 KiB
TypeScript

import type { APIRequestContext } from '@playwright/test'
const mailpitApiBase =
process.env.MAILPIT_API_URL?.replace(/\/$/, '') || 'http://localhost:8025'
interface MailpitAddress {
Name: string
Address: string
}
interface MailpitMessageSummary {
ID: string
To: MailpitAddress[]
Subject: string
}
interface MailpitMessagesResponse {
messages: MailpitMessageSummary[]
}
interface MailpitMessageDetail {
Text?: string
HTML?: string
}
export async function clearMailpit(request: APIRequestContext): Promise<void> {
await request.delete(`${mailpitApiBase}/api/v1/messages`)
}
export async function countMessagesTo(
request: APIRequestContext,
recipientEmail: string,
): Promise<number> {
const listResponse = await request.get(`${mailpitApiBase}/api/v1/messages`)
if (!listResponse.ok()) return 0
const list = (await listResponse.json()) as MailpitMessagesResponse
const normalized = recipientEmail.toLowerCase().trim()
return (list.messages ?? []).filter((msg) =>
msg.To?.some((to) => to.Address.toLowerCase() === normalized),
).length
}
export async function waitForPasswordResetToken(
request: APIRequestContext,
recipientEmail: string,
options: { timeoutMs?: number; publicBaseUrl?: string } = {},
): Promise<string> {
const timeoutMs = options.timeoutMs ?? 20_000
const deadline = Date.now() + timeoutMs
const normalizedRecipient = recipientEmail.toLowerCase().trim()
while (Date.now() < deadline) {
const listResponse = await request.get(`${mailpitApiBase}/api/v1/messages`)
if (!listResponse.ok()) {
await sleep(500)
continue
}
const list = (await listResponse.json()) as MailpitMessagesResponse
for (const summary of list.messages ?? []) {
const matchesRecipient = summary.To?.some(
(to) => to.Address.toLowerCase() === normalizedRecipient,
)
if (!matchesRecipient) continue
const detailResponse = await request.get(
`${mailpitApiBase}/api/v1/message/${summary.ID}`,
)
if (!detailResponse.ok()) continue
const detail = (await detailResponse.json()) as MailpitMessageDetail
const body = detail.Text ?? detail.HTML ?? ''
const token = extractResetToken(body, options.publicBaseUrl)
if (token) return token
}
await sleep(500)
}
throw new Error(
`No password reset email for ${recipientEmail} in Mailpit within ${timeoutMs}ms`,
)
}
function extractResetToken(body: string, publicBaseUrl?: string): string | null {
const pathPattern = /\/aterstall-losenord\?token=([A-Za-z0-9_-]+)/
const pathMatch = body.match(pathPattern)
if (pathMatch) return pathMatch[1]
if (publicBaseUrl) {
const escaped = publicBaseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const fullPattern = new RegExp(
`${escaped}/aterstall-losenord\\?token=([A-Za-z0-9_-]+)`,
)
const fullMatch = body.match(fullPattern)
if (fullMatch) return fullMatch[1]
}
return null
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}