Standalone SMTP gateway for Personal Cloud Server (PCS) deployments. This service receives emails from containerized applications (Vaultwarden, Nextcloud, etc.) and forwards them over HTTPS to a relay backend (the orchestrator email API) for delivery via SendGrid.
App (Vaultwarden, Nextcloud, etc.)
│
│ SMTP (port 587)
↓
smtp container (this service)
│
│ HTTPS API call
↓
Relay backend (orchestrator)
│
↓
SendGrid → Recipient
- SMTP Relay: Accepts SMTP connections on port 587
- Email Parsing: Full RFC-compliant MIME email parsing with inline image support
- API Forwarding: Forwards emails to the relay backend with JWT authentication
- Network Isolation: Runs in private Docker network (pcs)
- Multi-platform: Supports linux/amd64 and linux/arm64
- Lightweight: ~20MB Alpine-based Docker image
| Variable | Required | Default | Description |
|---|---|---|---|
RELAY_CREDENTIAL |
Yes | - | The credential the relay verifies. Explicit mode: an opaque bearer token (a JWT on Yundera). Provider mode (nsl): the PCS provider string backend_url,userid,signature — the gateway derives the endpoint and forwards userid:signature. |
RELAY_ENDPOINT_URL |
Conditional | - | Base URL of the relay backend's email API (gateway POSTs to {url}/email/send). Required in explicit mode; optional in provider mode, where it defaults to {backend_url}/router/api. |
SMTP_PORT |
No | 587 |
SMTP listening port |
STATS_FILE |
No | /data/stats.json |
Delivery statistics file (see Delivery statistics). If it cannot be written, statistics are kept in memory only. |
Provider mode is detected automatically: a
RELAY_CREDENTIALwith exactly three comma-separated fields (backend_url,userid,signature) is treated as a provider string. A JWT contains no commas, so it always uses explicit mode.
services:
smtp:
image: ghcr.io/yundera/mail-gateway:1.1.0
container_name: smtp
hostname: smtp
restart: unless-stopped
environment:
RELAY_CREDENTIAL: "${RELAY_CREDENTIAL}"
RELAY_ENDPOINT_URL: "${RELAY_ENDPOINT_URL}"
SMTP_PORT: "587"
expose:
- "587"
volumes:
- smtp-data:/data # keeps delivery statistics across recreation
networks:
- pcs
volumes:
smtp-data:
networks:
pcs:
name: pcsUse a named volume for /data: the image creates /data owned by the unprivileged smtp user (uid 1000) and
Docker copies that ownership into a new named volume. A bind mount comes up root-owned, so the gateway cannot write it
and falls back to in-memory statistics.
- Every recipient gets a copy. The relay API takes one recipient per call, so each distinct envelope recipient (To, Cc and Bcc alike) is sent its own copy, addressed to them alone.
- The sender is enforced by the relay:
<app>.<user-domain>@<server-domain>. Only the local part of the SMTPMAIL FROMis used, as the app name (lowercased,[a-z0-9-], 20 chars max). - SMTP replies:
250when the relay accepted every copy. That includes a relay with no delivery provider configured, which answers OK withskipped: true; the statistics record those asskipped, notsent.451(temporary, the app should retry) when a copy was rate-limited (HTTP 429) or the relay could not be reached.554(permanent) when the relay refused a copy with any other error status. This outranks a451.
- If one copy of a multi-recipient email fails, the whole transaction fails. An app that retries may then deliver a second copy to the recipients that already got one.
Each delivery attempt (one per recipient) is recorded in STATS_FILE: the last 200 events (time, app, enforced
sender, recipient, outcome, error), plus hourly per-app counters kept for 30 days. Subjects and bodies are never
stored. The file is replaced atomically after every event.
Read the summary with the stats subcommand. It reads the file, so it needs no port and no relay configuration:
docker exec smtp /app/mail-gateway stats{
"version": "1.1.0",
"since": "2026-09-30T10:00:00Z",
"retentionDays": 30,
"totals": {
"h24": { "sent": 3, "failed": 0, "skipped": 0, "rateLimited": 0 },
"d7": { "sent": 14, "failed": 1, "skipped": 0, "rateLimited": 0 }
},
"apps": [
{ "app": "vaultwarden", "from": "vaultwarden.alice@nsl.sh", "last": "2026-09-30T11:58:02Z",
"sent": 9, "failed": 1, "skipped": 0, "rateLimited": 0 }
],
"recent": [
{ "time": "2026-09-30T11:58:02Z", "app": "vaultwarden", "from": "vaultwarden.alice@nsl.sh",
"to": "bob@example.com", "status": "sent" }
],
"persistent": true
}statusis one ofsent,skipped(accepted by a relay with no delivery provider),failedorrate_limited.appscovers the whole retention window, most recently active first.recentholds the last 20 events, newest first.- The totals count whole hours, so
h24can include up to 59 extra minutes. persistent: falsewith empty lists means the file does not exist: the server could not write it, or no server has run with thisSTATS_FILE.
Apps running in the same Docker network can connect to the SMTP service using the hostname smtp.
SMTP_HOST=smtp
SMTP_PORT=587
SMTP_FROM=vaultwarden@yourdomain.com
SMTP_SECURITY=off # No TLS needed within Docker networkIn Nextcloud admin settings:
- Server address: smtp
- Port: 587
- Encryption: None/STARTTLS
- Network Isolation: Service only accepts connections from the private
pcsDocker network - No Public Exposure: SMTP port is NOT exposed to the internet
- Relay authentication: every API call carries
RELAY_CREDENTIALas a bearer token (a JWT, oruserid:signaturein provider mode) - Rate Limiting: the relay enforces rate limits (100 emails/hour per user); the gateway answers
451so apps retry
docker build -t mail-gateway .docker run --rm \
-e RELAY_CREDENTIAL="your-credential" \
-e RELAY_ENDPOINT_URL="https://your-relay.example.com/service/pcs" \
-p 587:587 \
mail-gateway# Send a test email using telnet
telnet localhost 587
> EHLO test
> MAIL FROM:<test@example.com>
> RCPT TO:<recipient@example.com>
> DATA
> Subject: Test Email
>
> This is a test email body.
> .
> QUITThis service is automatically deployed to PCS instances via the docker-compose.yml template shipped with the mesh-router installer.
The service is automatically built and published to GitHub Container Registry on:
- Push to
mainbranch →latesttag - Version tags (e.g.,
v1.0.0) → version-specific tags
Check logs:
docker logs smtpCommon issues:
- Missing
RELAY_CREDENTIALenvironment variable - Port 587 already in use
- Network connectivity to orchestrator
- Check SMTP service logs:
docker logs smtp - Verify orchestrator URL is correct
- Check JWT token is valid
- Verify app is configured to use
smtpas hostname - Check orchestrator logs for API errors
Copyright Yundera Team