♻️ Replace Mailcatcher with Mailpit for local email testing (#2436)

This commit is contained in:
Alejandra
2026-08-17 10:38:38 +02:00
committed by GitHub
parent 75b4026443
commit 981ae061b4
9 changed files with 81 additions and 88 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
version: "0.11.18"
- run: docker compose down -v --remove-orphans
- run: docker compose up -d db mailcatcher
- run: docker compose up -d db mailpit
- name: Migrate DB
run: uv run bash scripts/prestart.sh
working-directory: backend
+1 -1
View File
@@ -22,7 +22,7 @@
- 🔒 Secure password hashing by default.
- 🔑 JWT (JSON Web Token) authentication.
- 📫 Email-based password recovery.
- 📬 [Mailcatcher](https://mailcatcher.me) for local email testing during development.
- 📬 [Mailpit](https://mailpit.axllent.org) for local email testing during development.
- ✅ Tests with [Pytest](https://pytest.org).
- 🏭 CI (continuous integration) and CD (continuous deployment) based on GitHub Actions.
+2 -2
View File
@@ -9,10 +9,10 @@
Run the backend locally and connect it to PostgreSQL in Docker Compose.
From the project root, start PostgreSQL and Mailcatcher:
From the project root, start PostgreSQL and Mailpit:
```console
$ docker compose up -d db mailcatcher
$ docker compose up -d db mailpit
```
Then, from `./backend/`, install the dependencies, prepare the database, and start the development server:
+6 -6
View File
@@ -64,14 +64,14 @@ services:
- ./backend/htmlcov:/app/backend/htmlcov
environment:
FASTAPI_ENV: "development"
SMTP_HOST: "mailcatcher"
SMTP_HOST: "mailpit"
SMTP_PORT: "1025"
SMTP_TLS: "false"
mailcatcher:
image: schickling/mailcatcher
mailpit:
image: axllent/mailpit
ports:
- "1080:1080"
- "8025:8025"
- "1025:1025"
playwright:
@@ -83,13 +83,13 @@ services:
ipc: host
depends_on:
- backend
- mailcatcher
- mailpit
environment:
- FIRST_SUPERUSER=${FIRST_SUPERUSER:?Variable not set}
- FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD:?Variable not set}
- PLAYWRIGHT_BASE_URL=http://backend:8000
- VITE_API_URL=http://backend:8000
- MAILCATCHER_HOST=http://mailcatcher:1080
- MAILPIT_HOST=http://mailpit:8025
# For the reports when run locally
- PLAYWRIGHT_HTML_HOST=0.0.0.0
- CI=${CI:-}
+6 -6
View File
@@ -2,12 +2,12 @@
## Local Development
For local development, run PostgreSQL and Mailcatcher with Docker Compose, and run the FastAPI and Vite development servers locally.
For local development, run PostgreSQL and Mailpit with Docker Compose, and run the FastAPI and Vite development servers locally.
Start the supporting services:
```bash
docker compose up -d db mailcatcher
docker compose up -d db mailpit
```
Then, from the `backend` directory, install the dependencies and prepare the database:
@@ -38,7 +38,7 @@ Backend API: <http://localhost:8000>
Automatic interactive API documentation with Swagger UI: <http://localhost:8000/docs>
Mailcatcher: <http://localhost:1080>
Mailpit: <http://localhost:8025>
The frontend development server uses the backend at `http://localhost:8000`, as configured in `frontend/.env`.
@@ -71,15 +71,15 @@ Adminer, database web administration: <http://localhost:8080>
Traefik UI, to see how the routes are being handled by the proxy: <http://localhost:8090>
Mailcatcher: <http://localhost:1080>
Mailpit: <http://localhost:8025>
Stop a locally running FastAPI server before starting the Compose backend because both use port `8000`.
**Note**: The first time you start the stack, it might take a minute for all the services to be ready. To monitor it, use `docker compose logs`, or `docker compose logs backend` for the backend service.
## Mailcatcher
## Mailpit
Mailcatcher captures emails sent during local development instead of delivering them. The local backend connects to it at `localhost:1025`, and the Compose backend connects to the `mailcatcher` service. Captured emails are available at <http://localhost:1080>.
[Mailpit](https://mailpit.axllent.org) captures emails sent during local development instead of delivering them. The local backend connects to it at `localhost:1025`, and the Compose backend connects to the `mailpit` service. Captured emails are available at <http://localhost:8025>.
## Docker Compose Files and Environment Variables
+1 -1
View File
@@ -1,2 +1,2 @@
VITE_API_URL=http://localhost:8000
MAILCATCHER_HOST=http://localhost:1080
MAILPIT_HOST=http://localhost:8025
+5 -9
View File
@@ -1,5 +1,5 @@
import { expect, test } from "@playwright/test"
import { findLastEmail } from "./utils/mailcatcher"
import { emailUrl, findLastEmail } from "./utils/mailpit"
import { randomEmail, randomPassword } from "./utils/random"
import { logInUser, signUpNewUser } from "./utils/user"
@@ -46,13 +46,11 @@ test("User can reset password successfully using the link", async ({
const emailData = await findLastEmail({
request,
filter: (e) => e.recipients.includes(`<${email}>`),
query: `to:${email}`,
timeout: 5000,
})
await page.goto(
`${process.env.MAILCATCHER_HOST}/messages/${emailData.id}.html`,
)
await page.goto(emailUrl(emailData))
const selector = 'a[href*="/reset-password?token="]'
@@ -99,13 +97,11 @@ test("Weak new password validation", async ({ page, request }) => {
const emailData = await findLastEmail({
request,
filter: (e) => e.recipients.includes(`<${email}>`),
query: `to:${email}`,
timeout: 5000,
})
await page.goto(
`${process.env.MAILCATCHER_HOST}/messages/${emailData.id}.html`,
)
await page.goto(emailUrl(emailData))
const selector = 'a[href*="/reset-password?token="]'
const url = await page.getAttribute(selector, "href")
-62
View File
@@ -1,62 +0,0 @@
import type { APIRequestContext } from "@playwright/test"
type Email = {
id: number
recipients: string[]
subject: string
}
async function findEmail({
request,
filter,
}: {
request: APIRequestContext
filter?: (email: Email) => boolean
}) {
const response = await request.get(`${process.env.MAILCATCHER_HOST}/messages`)
let emails = await response.json()
if (filter) {
emails = emails.filter(filter)
}
const email = emails[emails.length - 1]
if (email) {
return email as Email
}
return null
}
export function findLastEmail({
request,
filter,
timeout = 5000,
}: {
request: APIRequestContext
filter?: (email: Email) => boolean
timeout?: number
}) {
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("Timeout while trying to get latest email")),
timeout,
),
)
const checkEmails = async () => {
while (true) {
const emailData = await findEmail({ request, filter })
if (emailData) {
return emailData
}
// Wait for 100ms before checking again
await new Promise((resolve) => setTimeout(resolve, 100))
}
}
return Promise.race([timeoutPromise, checkEmails()])
}
+59
View File
@@ -0,0 +1,59 @@
import type { APIRequestContext } from "@playwright/test"
type Address = {
Name: string
Address: string
}
type Email = {
ID: string
To: Address[]
Subject: string
}
async function findEmail({
request,
query,
}: {
request: APIRequestContext
query: string
}) {
const response = await request.get(
`${process.env.MAILPIT_HOST}/api/v1/search`,
{
params: { query, limit: 1 },
},
)
const { messages }: { messages: Email[] } = await response.json()
return messages[0] ?? null
}
export async function findLastEmail({
request,
query,
timeout = 5000,
}: {
request: APIRequestContext
query: string
timeout?: number
}) {
const deadline = Date.now() + timeout
while (Date.now() < deadline) {
const email = await findEmail({ request, query })
if (email) {
return email
}
await new Promise((resolve) => setTimeout(resolve, 100))
}
throw new Error(`Timeout while trying to get the latest email for "${query}"`)
}
export function emailUrl(email: Email) {
return `${process.env.MAILPIT_HOST}/view/${email.ID}.html`
}