docs(email): SMTP setup, the three postures, and the upgrade note
The operator-facing half of engagement Phase 1. README's stack table and security section, plus both .env.example files, all pointed at the removed Connect Gmail flow. The env comments now name the three supported postures rather than one provider — a relay as the recommendation, smtp.gmail.com:587 with an app password as the shortest migration, an unauthenticated local MTA as the third — and point at docs/website/UPGRADE_NOTES.md for the deployment this actually happens to. The OpenAPI spec is regenerated: two routes gone, three annotations rewritten, and the dashboard's new warnings[] documented. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
14
.env.example
14
.env.example
@@ -57,7 +57,7 @@ DB_ROOT_PASSWORD=change-me-root-password
|
|||||||
# Auth
|
# Auth
|
||||||
JWT_SECRET=change-me-to-a-long-random-string
|
JWT_SECRET=change-me-to-a-long-random-string
|
||||||
# Encrypts every secret this site stores at rest (AES-256-GCM): OAuth client
|
# Encrypts every secret this site stores at rest (AES-256-GCM): OAuth client
|
||||||
# secrets, the Discord bot token, the Gmail refresh token, the uo-link auth
|
# secrets, the Discord bot token, the mail transport credentials, the uo-link auth
|
||||||
# token. REQUIRED in production — with NODE_ENV=production the app REFUSES TO
|
# token. REQUIRED in production — with NODE_ENV=production the app REFUSES TO
|
||||||
# START without it (utils/secretBox.js), so a Compose deployment that leaves it
|
# START without it (utils/secretBox.js), so a Compose deployment that leaves it
|
||||||
# blank crash-loops before it ever listens. Development falls back to a key
|
# blank crash-loops before it ever listens. Development falls back to a key
|
||||||
@@ -98,10 +98,14 @@ TOTP_CHALLENGE_TTL=5m
|
|||||||
ADMIN_USERNAME=
|
ADMIN_USERNAME=
|
||||||
ADMIN_PASSWORD=
|
ADMIN_PASSWORD=
|
||||||
|
|
||||||
# Email is configured in Admin → Settings → Email (Gmail over OAuth2), not via
|
# Email is configured in Admin → Settings → Email, not via env: pick a mail
|
||||||
# env. It reuses the Google auth provider's OAuth client and stores an encrypted
|
# transport (SMTP) and enter its host, port and credentials, which are stored
|
||||||
# refresh token in the DB. Until it's connected, the contact form falls back to
|
# encrypted in the DB. Three postures work — a relay (Mailgun/SES/Postmark) is
|
||||||
# a mailto: link (recipient = the `contact_email` site setting).
|
# the recommended one, a mailbox provider over SMTP (e.g. smtp.gmail.com:587
|
||||||
|
# with an app password) is the simplest, and an unauthenticated local MTA on
|
||||||
|
# port 25 needs no credentials at all. Until one is configured the contact form
|
||||||
|
# falls back to a mailto: link (recipient = the `contact_email` site setting).
|
||||||
|
# Upgrading from the removed Gmail connect flow: see docs/website/UPGRADE_NOTES.md.
|
||||||
|
|
||||||
# CORS — only needed for local dev when the Vite dev server is a different origin.
|
# CORS — only needed for local dev when the Vite dev server is a different origin.
|
||||||
CLIENT_ORIGIN=http://localhost:5173
|
CLIENT_ORIGIN=http://localhost:5173
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -151,7 +151,7 @@ flowchart TB
|
|||||||
| Auth | Session service over JWT: httpOnly cookie (web) + bearer access/refresh tokens (mobile), bcrypt hashing, optional TOTP 2FA (`speakeasy` + `qrcode`), pluggable OAuth2/OIDC SSO (built-in Google & Discord + generic) |
|
| Auth | Session service over JWT: httpOnly cookie (web) + bearer access/refresh tokens (mobile), bcrypt hashing, optional TOTP 2FA (`speakeasy` + `qrcode`), pluggable OAuth2/OIDC SSO (built-in Google & Discord + generic) |
|
||||||
| Database | MariaDB 11 (own container) |
|
| Database | MariaDB 11 (own container) |
|
||||||
| Frontend | React 18, Vite 5, React Router 6 |
|
| Frontend | React 18, Vite 5, React Router 6 |
|
||||||
| Email | Nodemailer via Gmail OAuth2 (configured in admin), with a `mailto:` fallback |
|
| Email | Nodemailer over a configurable mail transport — SMTP (relay, mailbox provider or your own MTA), set up in the admin panel — with a `mailto:` fallback |
|
||||||
| API docs | OpenAPI 3.0 via `swagger-autogen`, served with `swagger-ui-express` at `/api/docs` |
|
| API docs | OpenAPI 3.0 via `swagger-autogen`, served with `swagger-ui-express` at `/api/docs` |
|
||||||
| Deploy | Docker Compose, any reverse proxy (Pangolin, Nginx, Caddy, Traefik, …) |
|
| Deploy | Docker Compose, any reverse proxy (Pangolin, Nginx, Caddy, Traefik, …) |
|
||||||
|
|
||||||
@@ -584,7 +584,7 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
|
|||||||
| `TOTP_ISSUER` | `BRAND_NAME` | label shown in authenticator apps for optional per-user 2FA |
|
| `TOTP_ISSUER` | `BRAND_NAME` | label shown in authenticator apps for optional per-user 2FA |
|
||||||
| `TOTP_CHALLENGE_TTL` | `5m` | lifetime of the short-lived post-password "awaiting code" step |
|
| `TOTP_CHALLENGE_TTL` | `5m` | lifetime of the short-lived post-password "awaiting code" step |
|
||||||
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | — | first-admin bootstrap (first boot only) |
|
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | — | first-admin bootstrap (first boot only) |
|
||||||
| _Email_ | — | configured in Admin → Settings → Email (Gmail OAuth2), not via env; recipient = `contact_email` setting |
|
| _Email_ | — | configured in Admin → Settings → Email (transport + credentials), never via env; recipient = `contact_email` setting. Upgrading from the removed Gmail connect flow: see [`docs/website/UPGRADE_NOTES.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/UPGRADE_NOTES.md) |
|
||||||
| `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only |
|
| `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only |
|
||||||
| `LOG_LEVEL` / `FILE_LOG_LEVEL` | `info` / `debug` | console / file verbosity |
|
| `LOG_LEVEL` / `FILE_LOG_LEVEL` | `info` / `debug` | console / file verbosity |
|
||||||
| `LOG_TO_FILE` / `LOG_DIR` / `LOG_FILE` | `true` / `<server>/logs` / `app.log` | log file (bind-mounted to `./logs` in Docker) |
|
| `LOG_TO_FILE` / `LOG_DIR` / `LOG_FILE` | `true` / `<server>/logs` / `app.log` | log file (bind-mounted to `./logs` in Docker) |
|
||||||
@@ -677,9 +677,11 @@ run this repo as UOMysticmoon.
|
|||||||
|
|
||||||
- `helmet`, admin routes `noindex` + `robots.txt` disallow, `trust proxy` for correct client IPs
|
- `helmet`, admin routes `noindex` + `robots.txt` disallow, `trust proxy` for correct client IPs
|
||||||
behind a reverse proxy (see `TRUST_PROXY`), first admin seeded from env (no hardcoded credentials),
|
behind a reverse proxy (see `TRUST_PROXY`), first admin seeded from env (no hardcoded credentials),
|
||||||
`.env` git-ignored. Passwords and request bodies are never logged. Email sends through Gmail
|
`.env` git-ignored. Passwords and request bodies are never logged. Email sends through a mail
|
||||||
OAuth2 configured in the admin (refresh token stored AES-GCM-encrypted, never in env); the
|
transport configured in the admin, whose credentials are stored AES-GCM-encrypted and are
|
||||||
contact form falls back to a `mailto:` link when unconfigured.
|
write-only over the API (never returned, never in env); no transport ships a default host or
|
||||||
|
sender, so an unconfigured deployment sends nowhere. The contact form falls back to a `mailto:`
|
||||||
|
link when unconfigured.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -80,10 +80,12 @@ TOTP_CHALLENGE_TTL=5m
|
|||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_PASSWORD=change-me-admin-password
|
ADMIN_PASSWORD=change-me-admin-password
|
||||||
|
|
||||||
# Email is configured in Admin → Settings → Email (Gmail over OAuth2), not here.
|
# Email is configured in Admin → Settings → Email, not here: pick a mail
|
||||||
# It reuses the Google auth provider's OAuth client and stores an encrypted
|
# transport (SMTP) and enter its host, port and credentials, stored encrypted in
|
||||||
# refresh token in the DB. The contact recipient is the `contact_email` site
|
# the DB. A relay is the recommended posture; smtp.gmail.com:587 with an app
|
||||||
|
# password is the simplest. The contact recipient is the `contact_email` site
|
||||||
# setting; while email is unconfigured the contact form falls back to a mailto: link.
|
# setting; while email is unconfigured the contact form falls back to a mailto: link.
|
||||||
|
# Upgrading from the removed Gmail connect flow: see docs/website/UPGRADE_NOTES.md.
|
||||||
|
|
||||||
CLIENT_ORIGIN=http://localhost:5173
|
CLIENT_ORIGIN=http://localhost:5173
|
||||||
|
|
||||||
|
|||||||
@@ -1029,6 +1029,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"warnings": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Operator warnings needing action; empty when there is nothing to say",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "EMAIL_TRANSPORT_MIGRATION"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"href": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"recent_activity": {
|
"recent_activity": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -1205,11 +1224,11 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Admin · Email"
|
"Admin · Email"
|
||||||
],
|
],
|
||||||
"summary": "Get email delivery config + status (admin only)",
|
"summary": "Get email delivery config, status and the transport catalog (admin only)",
|
||||||
"description": "",
|
"description": "Credentials are write-only: secret fields are never returned, only a per-field `secretsSet` flag. `transports` carries each registered transport's declared credential fields, which is what the admin form renders.",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Config (refresh token stripped) + status",
|
"description": "Config (secrets stripped) + status + transport catalog",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -1257,7 +1276,7 @@
|
|||||||
"Admin · Email"
|
"Admin · Email"
|
||||||
],
|
],
|
||||||
"summary": "Update email delivery config (admin only)",
|
"summary": "Update email delivery config (admin only)",
|
||||||
"description": "Set the From display name and enabled toggle. Enabling requires a connected Gmail account.",
|
"description": "Set the transport, sender identity, credentials and enabled toggle. `credential` is a patch against the stored blob — a secret field submitted empty keeps its stored value. Enabling requires complete credentials and a sender address.",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Updated config",
|
"description": "Updated config",
|
||||||
@@ -1271,7 +1290,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"400": {
|
"400": {
|
||||||
"description": "Cannot enable before connecting a mailbox",
|
"description": "Unknown transport, or cannot enable without complete credentials and a sender address",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -1318,9 +1337,25 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"transport": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "smtp"
|
||||||
|
},
|
||||||
|
"senderEmail": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "email"
|
||||||
|
},
|
||||||
"senderName": {
|
"senderName": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"replyTo": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "email"
|
||||||
|
},
|
||||||
|
"credential": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
"enabled": {
|
"enabled": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
@@ -1331,128 +1366,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/admin/email/connect/callback": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"Admin · Email"
|
|
||||||
],
|
|
||||||
"summary": "OAuth2 callback — stores the refresh token, redirects to Settings",
|
|
||||||
"description": "",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "code",
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "state",
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "error",
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"302": {
|
|
||||||
"description": "Redirect back to /admin/settings"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"cookieAuth": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"bearerAuth": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/v1/admin/email/connect/start": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"Admin · Email"
|
|
||||||
],
|
|
||||||
"summary": "Begin the Gmail OAuth2 connect flow (admin only)",
|
|
||||||
"description": "Returns { url } to redirect the browser to Google. Reuses the google SSO OAuth client.",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Authorization URL",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"url": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"400": {
|
|
||||||
"description": "Google OAuth client not configured",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/Error"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"description": "Not authenticated",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/Error"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"description": "Admin role required",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/Error"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"500": {
|
|
||||||
"description": "Internal Server Error"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"cookieAuth": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"bearerAuth": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/v1/admin/email/disconnect": {
|
"/api/v1/admin/email/disconnect": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
"Admin · Email"
|
"Admin · Email"
|
||||||
],
|
],
|
||||||
"summary": "Disconnect Gmail and disable email (admin only)",
|
"summary": "Clear the stored credentials and disable email (admin only)",
|
||||||
"description": "",
|
"description": "",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Disconnected config",
|
"description": "Cleared config",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -1502,7 +1425,7 @@
|
|||||||
"Admin · Email"
|
"Admin · Email"
|
||||||
],
|
],
|
||||||
"summary": "Send a test email (admin only)",
|
"summary": "Send a test email (admin only)",
|
||||||
"description": "",
|
"description": "The real verification of the configuration — host, port, TLS mode, credentials, and whether the relay accepts the configured sender. Failures return a specific diagnostic.",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Sent",
|
"description": "Sent",
|
||||||
|
|||||||
Reference in New Issue
Block a user