♻️ 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 # See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
version: "0.11.18" version: "0.11.18"
- run: docker compose down -v --remove-orphans - run: docker compose down -v --remove-orphans
- run: docker compose up -d db mailcatcher - run: docker compose up -d db mailpit
- name: Migrate DB - name: Migrate DB
run: uv run bash scripts/prestart.sh run: uv run bash scripts/prestart.sh
working-directory: backend working-directory: backend
+1 -1
View File
@@ -22,7 +22,7 @@
- 🔒 Secure password hashing by default. - 🔒 Secure password hashing by default.
- 🔑 JWT (JSON Web Token) authentication. - 🔑 JWT (JSON Web Token) authentication.
- 📫 Email-based password recovery. - 📫 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). - ✅ Tests with [Pytest](https://pytest.org).
- 🏭 CI (continuous integration) and CD (continuous deployment) based on GitHub Actions. - 🏭 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. 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 ```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: 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 - ./backend/htmlcov:/app/backend/htmlcov
environment: environment:
FASTAPI_ENV: "development" FASTAPI_ENV: "development"
SMTP_HOST: "mailcatcher" SMTP_HOST: "mailpit"
SMTP_PORT: "1025" SMTP_PORT: "1025"
SMTP_TLS: "false" SMTP_TLS: "false"
mailcatcher: mailpit:
image: schickling/mailcatcher image: axllent/mailpit
ports: ports:
- "1080:1080" - "8025:8025"
- "1025:1025" - "1025:1025"
playwright: playwright:
@@ -83,13 +83,13 @@ services:
ipc: host ipc: host
depends_on: depends_on:
- backend - backend
- mailcatcher - mailpit
environment: environment:
- FIRST_SUPERUSER=${FIRST_SUPERUSER:?Variable not set} - FIRST_SUPERUSER=${FIRST_SUPERUSER:?Variable not set}
- FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD:?Variable not set} - FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD:?Variable not set}
- PLAYWRIGHT_BASE_URL=http://backend:8000 - PLAYWRIGHT_BASE_URL=http://backend:8000
- VITE_API_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 # For the reports when run locally
- PLAYWRIGHT_HTML_HOST=0.0.0.0 - PLAYWRIGHT_HTML_HOST=0.0.0.0
- CI=${CI:-} - CI=${CI:-}
+6 -6
View File
@@ -2,12 +2,12 @@
## Local Development ## 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: Start the supporting services:
```bash ```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: 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> 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`. 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> 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`. 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. **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 ## Docker Compose Files and Environment Variables
+1 -1
View File
@@ -1,2 +1,2 @@
VITE_API_URL=http://localhost:8000 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 { expect, test } from "@playwright/test"
import { findLastEmail } from "./utils/mailcatcher" import { emailUrl, findLastEmail } from "./utils/mailpit"
import { randomEmail, randomPassword } from "./utils/random" import { randomEmail, randomPassword } from "./utils/random"
import { logInUser, signUpNewUser } from "./utils/user" import { logInUser, signUpNewUser } from "./utils/user"
@@ -46,13 +46,11 @@ test("User can reset password successfully using the link", async ({
const emailData = await findLastEmail({ const emailData = await findLastEmail({
request, request,
filter: (e) => e.recipients.includes(`<${email}>`), query: `to:${email}`,
timeout: 5000, timeout: 5000,
}) })
await page.goto( await page.goto(emailUrl(emailData))
`${process.env.MAILCATCHER_HOST}/messages/${emailData.id}.html`,
)
const selector = 'a[href*="/reset-password?token="]' const selector = 'a[href*="/reset-password?token="]'
@@ -99,13 +97,11 @@ test("Weak new password validation", async ({ page, request }) => {
const emailData = await findLastEmail({ const emailData = await findLastEmail({
request, request,
filter: (e) => e.recipients.includes(`<${email}>`), query: `to:${email}`,
timeout: 5000, timeout: 5000,
}) })
await page.goto( await page.goto(emailUrl(emailData))
`${process.env.MAILCATCHER_HOST}/messages/${emailData.id}.html`,
)
const selector = 'a[href*="/reset-password?token="]' const selector = 'a[href*="/reset-password?token="]'
const url = await page.getAttribute(selector, "href") 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`
}