diff --git a/.env.example b/.env.example index 22abc57..4eccd07 100644 --- a/.env.example +++ b/.env.example @@ -57,7 +57,7 @@ DB_ROOT_PASSWORD=change-me-root-password # Auth JWT_SECRET=change-me-to-a-long-random-string # 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 # 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 @@ -98,10 +98,14 @@ TOTP_CHALLENGE_TTL=5m ADMIN_USERNAME= ADMIN_PASSWORD= -# Email is configured in Admin → Settings → Email (Gmail over OAuth2), not via -# env. It reuses the Google auth provider's OAuth client and stores an encrypted -# refresh token in the DB. Until it's connected, the contact form falls back to -# a mailto: link (recipient = the `contact_email` site setting). +# Email is configured in Admin → Settings → Email, not via env: pick a mail +# transport (SMTP) and enter its host, port and credentials, which are stored +# encrypted in the DB. Three postures work — a relay (Mailgun/SES/Postmark) is +# 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. CLIENT_ORIGIN=http://localhost:5173 diff --git a/README.md b/README.md index 097b90b..306d44d 100644 --- a/README.md +++ b/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) | | Database | MariaDB 11 (own container) | | 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` | | 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_CHALLENGE_TTL` | `5m` | lifetime of the short-lived post-password "awaiting code" step | | `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 | | `LOG_LEVEL` / `FILE_LOG_LEVEL` | `info` / `debug` | console / file verbosity | | `LOG_TO_FILE` / `LOG_DIR` / `LOG_FILE` | `true` / `/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 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 - OAuth2 configured in the admin (refresh token stored AES-GCM-encrypted, never in env); the - contact form falls back to a `mailto:` link when unconfigured. + `.env` git-ignored. Passwords and request bodies are never logged. Email sends through a mail + transport configured in the admin, whose credentials are stored AES-GCM-encrypted and are + 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. --- diff --git a/server/.env.example b/server/.env.example index 988a7d8..6fd0e84 100644 --- a/server/.env.example +++ b/server/.env.example @@ -80,10 +80,12 @@ TOTP_CHALLENGE_TTL=5m ADMIN_USERNAME=admin ADMIN_PASSWORD=change-me-admin-password -# Email is configured in Admin → Settings → Email (Gmail over OAuth2), not here. -# It reuses the Google auth provider's OAuth client and stores an encrypted -# refresh token in the DB. The contact recipient is the `contact_email` site +# Email is configured in Admin → Settings → Email, not here: pick a mail +# transport (SMTP) and enter its host, port and credentials, stored encrypted in +# 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. +# Upgrading from the removed Gmail connect flow: see docs/website/UPGRADE_NOTES.md. CLIENT_ORIGIN=http://localhost:5173 diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 38951f9..d410b34 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -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": { "type": "array", "items": { @@ -1205,11 +1224,11 @@ "tags": [ "Admin · Email" ], - "summary": "Get email delivery config + status (admin only)", - "description": "", + "summary": "Get email delivery config, status and the transport catalog (admin only)", + "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": { "200": { - "description": "Config (refresh token stripped) + status", + "description": "Config (secrets stripped) + status + transport catalog", "content": { "application/json": { "schema": { @@ -1257,7 +1276,7 @@ "Admin · Email" ], "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": { "200": { "description": "Updated config", @@ -1271,7 +1290,7 @@ } }, "400": { - "description": "Cannot enable before connecting a mailbox", + "description": "Unknown transport, or cannot enable without complete credentials and a sender address", "content": { "application/json": { "schema": { @@ -1318,9 +1337,25 @@ "schema": { "type": "object", "properties": { + "transport": { + "type": "string", + "example": "smtp" + }, + "senderEmail": { + "type": "string", + "format": "email" + }, "senderName": { "type": "string" }, + "replyTo": { + "type": "string", + "format": "email" + }, + "credential": { + "type": "object", + "additionalProperties": true + }, "enabled": { "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": { "post": { "tags": [ "Admin · Email" ], - "summary": "Disconnect Gmail and disable email (admin only)", + "summary": "Clear the stored credentials and disable email (admin only)", "description": "", "responses": { "200": { - "description": "Disconnected config", + "description": "Cleared config", "content": { "application/json": { "schema": { @@ -1502,7 +1425,7 @@ "Admin · Email" ], "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": { "200": { "description": "Sent",