Bundle a Mailpit catcher and default Compose to SMTP

Adds a mailpit service (axllent/mailpit, ~15 MB, in-memory) to
docker-compose.yml — chosen over the unmaintained MailHog for the same
footprint. It has no profile, so `docker compose up -d` starts it alongside
the API; the app defaults to MAIL_TRANSPORT=smtp -> mailpit:1025 (no
auth/TLS) and mail is read at http://localhost:8025.

Also change the default MAIL_FROM to no-reply@todo.test: PHPMailer v7 rejects
the dotless no-reply@localhost as an invalid address.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 08:40:36 +01:00
co-authored by Claude Sonnet 5
parent f9b65cc4a7
commit 64ba21795b
4 changed files with 29 additions and 15 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ APP_URL=http://localhost:5173
# smtp — the SMTP server configured below # smtp — the SMTP server configured below
# log — append messages to MAIL_LOG_PATH instead of sending (dev/test) # log — append messages to MAIL_LOG_PATH instead of sending (dev/test)
MAIL_TRANSPORT=mail MAIL_TRANSPORT=mail
MAIL_FROM=no-reply@localhost MAIL_FROM=no-reply@todo.test
MAIL_FROM_NAME=Todo List MAIL_FROM_NAME=Todo List
MAIL_LOG_PATH=storage/mail.log MAIL_LOG_PATH=storage/mail.log
+10 -5
View File
@@ -30,6 +30,10 @@ docker compose up -d
This builds a PHP 8.3 + Apache image, applies migrations, and serves the API at This builds a PHP 8.3 + Apache image, applies migrations, and serves the API at
<http://localhost:8080> (e.g. `curl http://localhost:8080/api/health`). <http://localhost:8080> (e.g. `curl http://localhost:8080/api/health`).
- A **[Mailpit](https://mailpit.axllent.org/)** container (the maintained MailHog
successor — one ~15 MB Go binary, messages kept in memory) also starts. The API
sends all email to it; read it at <http://localhost:8025>. Set
`MAIL_TRANSPORT=mail` or `=smtp` (with `MAIL_SMTP_*`) to send for real.
- The project directory is bind-mounted into the container, so editing PHP - The project directory is bind-mounted into the container, so editing PHP
source takes effect without a rebuild (within ~2s, due to the opcache source takes effect without a rebuild (within ~2s, due to the opcache
revalidation interval). `vendor/` is used from the host — run `composer` revalidation interval). `vendor/` is used from the host — run `composer`
@@ -103,13 +107,14 @@ environment). See [.env.example](.env.example).
| `JWT_TTL` | `86400` | Token lifetime in seconds | | `JWT_TTL` | `86400` | Token lifetime in seconds |
| `APP_URL` | `http://localhost:5173` | Frontend base URL used to build magic links | | `APP_URL` | `http://localhost:5173` | Frontend base URL used to build magic links |
| `MAIL_TRANSPORT` | `mail` | `mail` (PHP `mail()`), `smtp`, or `log` (append to a file) | | `MAIL_TRANSPORT` | `mail` | `mail` (PHP `mail()`), `smtp`, or `log` (append to a file) |
| `MAIL_FROM` / `MAIL_FROM_NAME` | `no-reply@localhost` / `Todo List` | Envelope sender | | `MAIL_FROM` / `MAIL_FROM_NAME` | `no-reply@todo.test` / `Todo List` | Envelope sender |
| `MAIL_LOG_PATH` | `storage/mail.log` | Where `log` transport writes | | `MAIL_LOG_PATH` | `storage/mail.log` | Where `log` transport writes |
| `MAIL_SMTP_HOST` / `_PORT` / `_USERNAME` / `_PASSWORD` / `_ENCRYPTION` | — / `587` / — / — / `tls` | Used only when `MAIL_TRANSPORT=smtp` | | `MAIL_SMTP_HOST` / `_PORT` / `_USERNAME` / `_PASSWORD` / `_ENCRYPTION` | — / `587` / — / — / `tls` | Used only when `MAIL_TRANSPORT=smtp` |
SMTP is opt-in; without it the API falls back to PHP's `mail()`. The Docker Standalone, SMTP is opt-in and the API otherwise falls back to PHP's `mail()`.
Compose setup sets `MAIL_TRANSPORT=log` (the container has no MTA) — read the Under Docker Compose the default is `MAIL_TRANSPORT=smtp` pointed at the bundled
links with `docker compose exec app cat /var/www/storage/mail.log`. Mailpit container (`mailpit:1025`, no auth/TLS); open <http://localhost:8025> to
read what was "sent".
## API ## API
@@ -358,7 +363,7 @@ src/Repository/ Database access (User, EmailVerification, TodoList,
src/Support/Validator.php Request-body validation helper src/Support/Validator.php Request-body validation helper
migrations/*.sql Schema, applied by bin/migrate.php migrations/*.sql Schema, applied by bin/migrate.php
Dockerfile PHP 8.3 + Apache image Dockerfile PHP 8.3 + Apache image
docker-compose.yml One-command local stack (API; web via --profile frontend) docker-compose.yml Local stack: API + Mailpit; web via --profile frontend
docker/ Apache vhost + container entrypoint docker/ Apache vhost + container entrypoint
web/ Vue 3 + TypeScript + Vite PWA frontend web/ Vue 3 + TypeScript + Vite PWA frontend
``` ```
+17 -8
View File
@@ -14,21 +14,30 @@ services:
JWT_TTL: "${JWT_TTL:-86400}" JWT_TTL: "${JWT_TTL:-86400}"
# Magic links point at the frontend dev server. # Magic links point at the frontend dev server.
APP_URL: "${APP_URL:-http://localhost:5173}" APP_URL: "${APP_URL:-http://localhost:5173}"
# The container has no MTA, so log emails to a file by default. View them with: # Deliver to the Mailpit catcher below; read mail at http://localhost:8025.
# docker compose exec app cat /var/www/storage/mail.log MAIL_TRANSPORT: "${MAIL_TRANSPORT:-smtp}"
# Set to "smtp" (with MAIL_SMTP_*) or "mail" to actually send. MAIL_FROM: "${MAIL_FROM:-no-reply@todo.test}"
MAIL_TRANSPORT: "${MAIL_TRANSPORT:-log}" MAIL_SMTP_HOST: "${MAIL_SMTP_HOST:-mailpit}"
MAIL_FROM: "${MAIL_FROM:-no-reply@localhost}" MAIL_SMTP_PORT: "${MAIL_SMTP_PORT:-1025}"
MAIL_SMTP_HOST: "${MAIL_SMTP_HOST:-}"
MAIL_SMTP_PORT: "${MAIL_SMTP_PORT:-587}"
MAIL_SMTP_USERNAME: "${MAIL_SMTP_USERNAME:-}" MAIL_SMTP_USERNAME: "${MAIL_SMTP_USERNAME:-}"
MAIL_SMTP_PASSWORD: "${MAIL_SMTP_PASSWORD:-}" MAIL_SMTP_PASSWORD: "${MAIL_SMTP_PASSWORD:-}"
MAIL_SMTP_ENCRYPTION: "${MAIL_SMTP_ENCRYPTION:-tls}" MAIL_SMTP_ENCRYPTION: "${MAIL_SMTP_ENCRYPTION:-none}"
volumes: volumes:
# Live source: edit on the host, no image rebuild needed. # Live source: edit on the host, no image rebuild needed.
- .:/var/www/html - .:/var/www/html
# Persistent storage, kept outside /var/www/html to avoid a nested mount. # Persistent storage, kept outside /var/www/html to avoid a nested mount.
- storage:/var/www/storage - storage:/var/www/storage
depends_on:
- mailpit
restart: unless-stopped
# Development mail catcher (Mailpit — the maintained MailHog successor). ~15 MB,
# single Go binary, messages held in memory. Web UI: http://localhost:8025
mailpit:
image: axllent/mailpit:v1.31
ports:
- "8025:8025" # web UI + REST API
- "1025:1025" # SMTP (also reachable in-network as mailpit:1025)
restart: unless-stopped restart: unless-stopped
# Optional Vite dev server. Start it with: docker compose --profile frontend up -d # Optional Vite dev server. Start it with: docker compose --profile frontend up -d
+1 -1
View File
@@ -50,7 +50,7 @@ final class Config
$mail = new MailConfig( $mail = new MailConfig(
transport: strtolower(self::env('MAIL_TRANSPORT', 'mail')), transport: strtolower(self::env('MAIL_TRANSPORT', 'mail')),
fromAddress: self::env('MAIL_FROM', 'no-reply@localhost'), fromAddress: self::env('MAIL_FROM', 'no-reply@todo.test'),
fromName: self::env('MAIL_FROM_NAME', 'Todo List'), fromName: self::env('MAIL_FROM_NAME', 'Todo List'),
logPath: $mailLogPath, logPath: $mailLogPath,
smtpHost: self::env('MAIL_SMTP_HOST'), smtpHost: self::env('MAIL_SMTP_HOST'),