mirror of
https://github.com/fastapi/full-stack-fastapi-template.git
synced 2026-11-03 12:29:42 +00:00
♻️ Replace Mailcatcher with Mailpit for local email testing (#2436)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -1,2 +1,2 @@
|
||||
VITE_API_URL=http://localhost:8000
|
||||
MAILCATCHER_HOST=http://localhost:1080
|
||||
MAILPIT_HOST=http://localhost:8025
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()])
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
Reference in New Issue
Block a user