diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 8ea360d..f52630e 100644 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -13,7 +13,7 @@ FAIL=0 # locale/ catalogs are mechanically extracted from the impressum/privacy/terms templates above — # they cannot avoid carrying the same address/email strings. Treating them as public is consistent # with the source templates being public. -ALLOW_RE='^(app/templates/(impressum|privacy|terms)\.html|COMMERCIAL-LICENSE\.md|docs/gdpr-account-deletion-design\.md|docs/api-usage-guide\.md|docs/self-hosting\.md|docs/dpa-template\.md|docs-internal/.*|\.githooks/.*|\.github/workflows/scope-guard\.yml|CHANGELOG\.md|locale/.*\.(po|pot|mo)|\.env\.example)$' +ALLOW_RE='^(app/templates/(impressum|privacy|terms)\.html|COMMERCIAL-LICENSE\.md|docs/gdpr-account-deletion-design\.md|docs/api-usage-guide\.md|docs/self-hosting\.md|docs/dpa-template\.md|docs/email-setup\.md|docs-internal/.*|\.githooks/.*|\.github/workflows/scope-guard\.yml|CHANGELOG\.md|locale/.*\.(po|pot|mo)|\.env\.example)$' # Personal/operational identifiers that should never land in public code. PATTERNS='lennart\.seidel@icloud\.com|lennart@filemorph\.io|Reetwerder|21029 Hamburg' @@ -49,7 +49,7 @@ for f in $STAGED_FILES; do done # Block staging of files that should be in docs-internal/. -INTERNAL_PATHS='^docs/(admin-cockpit|email-setup|open-tasks|filemorph-io-runbook|marketing-plan|seo-strategy|business-case|claims-audit|launch-gate-snapshot|launch-readiness-tracker|seo-audit|user-acquisition-strategy|requirements-v2|sprint-5-multi-file-plan)\.md$' +INTERNAL_PATHS='^docs/(admin-cockpit|open-tasks|filemorph-io-runbook|marketing-plan|seo-strategy|business-case|claims-audit|launch-gate-snapshot|launch-readiness-tracker|seo-audit|user-acquisition-strategy|requirements-v2|sprint-5-multi-file-plan)\.md$' # Block staging of files that are ops-only and must never appear in the public repo. FORBIDDEN_PATHS='^(compose\.prod\.yml|deploy\.sh|\.env\.production(\..*)?|CLAUDE\.md)$|^runbooks/|^docs-internal/' diff --git a/.githooks/pre-push b/.githooks/pre-push index c50d8f5..5b09d10 100644 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -15,12 +15,12 @@ set -e ZERO=0000000000000000000000000000000000000000 # Same patterns as pre-commit — keep in sync. -ALLOW_RE='^(app/templates/(impressum|privacy|terms)\.html|COMMERCIAL-LICENSE\.md|docs/gdpr-account-deletion-design\.md|docs/api-usage-guide\.md|docs/self-hosting\.md|docs/dpa-template\.md|docs-internal/.*|\.githooks/.*|\.github/workflows/scope-guard\.yml|CHANGELOG\.md|locale/.*\.(po|pot|mo)|\.env\.example)$' +ALLOW_RE='^(app/templates/(impressum|privacy|terms)\.html|COMMERCIAL-LICENSE\.md|docs/gdpr-account-deletion-design\.md|docs/api-usage-guide\.md|docs/self-hosting\.md|docs/dpa-template\.md|docs/email-setup\.md|docs-internal/.*|\.githooks/.*|\.github/workflows/scope-guard\.yml|CHANGELOG\.md|locale/.*\.(po|pot|mo)|\.env\.example)$' PATTERNS='lennart\.seidel@icloud\.com|lennart@filemorph\.io|Reetwerder|21029 Hamburg' OPS_PATTERNS='/opt/filemorph(/|$|[[:space:]])|/var/log/filemorph|/home/deploy([[:space:]]|/)|Hetzner CX|HETZNER_HOST|HETZNER_SSH_USER|HETZNER_SSH_KEY|OPS_REPO_DISPATCH_PAT|GHCR_PAT|appleboy/ssh-action' SECRET_ASSIGN='(JWT_SECRET|SMTP_PASSWORD|STRIPE_SECRET_KEY|STRIPE_WEBHOOK_SECRET|DATABASE_URL|API_KEY|POSTGRES_PASSWORD|GHCR_PAT|OPS_REPO_DISPATCH_PAT|HETZNER_SSH_KEY)[[:space:]]*=[[:space:]]*[^[:space:]$]' SECRET_VALUES='sk_live_[a-zA-Z0-9]{8}|rk_live_[a-zA-Z0-9]{8}|ghp_[a-zA-Z0-9]{16}|gho_[a-zA-Z0-9]{16}|github_pat_[a-zA-Z0-9_]{16}|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----' -INTERNAL_PATHS='^docs/(admin-cockpit|email-setup|open-tasks|filemorph-io-runbook|marketing-plan|seo-strategy|business-case|claims-audit|launch-gate-snapshot|launch-readiness-tracker|seo-audit|user-acquisition-strategy|requirements-v2|sprint-5-multi-file-plan)\.md$' +INTERNAL_PATHS='^docs/(admin-cockpit|open-tasks|filemorph-io-runbook|marketing-plan|seo-strategy|business-case|claims-audit|launch-gate-snapshot|launch-readiness-tracker|seo-audit|user-acquisition-strategy|requirements-v2|sprint-5-multi-file-plan)\.md$' FORBIDDEN_PATHS='^(compose\.prod\.yml|deploy\.sh|\.env\.production(\..*)?|CLAUDE\.md)$|^runbooks/|^docs-internal/' FAIL=0 diff --git a/docs/email-setup.md b/docs/email-setup.md new file mode 100644 index 0000000..8e4cdc4 --- /dev/null +++ b/docs/email-setup.md @@ -0,0 +1,204 @@ +# Email Setup (Cloud Edition) + +FileMorph's Cloud-overlay features — user registration with email verification, +password reset, account-deletion confirmation, and (optionally) Stripe billing +receipts — depend on outbound SMTP. This guide walks through configuring it for +a self-hosted deployment. + +The Community Edition (anonymous-tier conversion / compression with API keys) +needs none of this. If you don't run the user-account features, leave the +`SMTP_*` envs empty and skip this document. + +--- + +## What needs SMTP + +| Feature | Endpoint | What email carries | +|---|---|---| +| Email verification | `POST /api/v1/auth/register` and `POST /api/v1/auth/resend-verification` | One-time link binding the verify-token to the user's email-at-issuance. **7-day TTL.** | +| Password reset | `POST /api/v1/auth/forgot-password` | Single-use reset link. **30-minute TTL**, invalidated by the next password change (hash-version pin). | +| Account-deletion confirmation | `DELETE /api/v1/auth/account` | Post-commit notification with the user-facing support contact. | +| Billing receipts | Stripe Customer Portal | Stripe sends these directly via the customer portal — FileMorph itself only sends transactional auth mail. | + +If `SMTP_HOST` is empty, every feature above degrades gracefully: + +- `/forgot-password` and `/resend-verification` return `503 Service Unavailable` + with a reason string the UI surfaces. +- `/register` still creates the user — the verification email is fire-and-forget. + The user can request a fresh link via `/resend-verification` once SMTP is wired. +- `/auth/account` deletes the user even if the confirmation email cannot be sent; + the deletion itself is the legally-binding action. + +--- + +## Required environment variables + +All documented in `.env.example`. The minimum for working transactional mail: + +```env +SMTP_HOST=smtp.example.com # your provider's SMTP relay +SMTP_PORT=587 # 587 = STARTTLS, 465 = implicit SSL +SMTP_USERNAME=no-reply@example.com # the auth user (often = FROM address) +SMTP_PASSWORD=... # provider-issued app password / SMTP secret +SMTP_FROM_EMAIL=no-reply@example.com # user-visible FROM address +SMTP_FROM_NAME=YourBrand # display name in the From: header +SMTP_REPLY_TO=hallo@example.com # optional; where users hit "reply" + +APP_BASE_URL=https://your-domain.example.com # used to build link URLs +``` + +`SMTP_FROM_EMAIL` and `SMTP_FROM_NAME` are what the recipient sees. There are +no hard-coded `@`-domains anywhere in the user-visible copy — every link, From: +address, and Reply-To: is taken from these variables. **Self-hosters ship their +own support identity end-to-end.** + +If `SMTP_FROM_EMAIL` is empty, the sender falls back to `SMTP_USERNAME`. Set +both explicitly in production so the From: address never silently changes +because someone rotated the SMTP login. + +--- + +## Picking a port (TLS mode) + +The TLS mode is chosen by port, no extra flag: + +- `SMTP_PORT=465` — implicit SSL from the first byte (`SMTPS`). Used by some + legacy providers; less common today. +- `SMTP_PORT=587` — plain connect, then `STARTTLS` upgrade. The modern default. + RFC 8314 § 3.3 recommends 587 for new deployments. + +Some cloud providers (notably Hetzner Cloud at the time of writing) block +outbound port 465 for new accounts as anti-abuse, while leaving 587 open. If +your provider does the same, use 587 — it's the path of least friction. Open +465 only if your SMTP provider requires it and your hosting provider allows +the egress. + +--- + +## Provider walk-throughs + +The application talks to any RFC-compliant SMTP relay. Three common paths: + +### A — Transactional ESP (Mailgun, Postmark, Brevo, ZeptoMail, Resend, …) + +Best for production: deliverability is the ESP's full-time job, and you get +DKIM/SPF/DMARC alignment plus a deliverability dashboard. + +Typical setup (vendor-specific values shown as placeholders): + +```env +SMTP_HOST=smtp..com +SMTP_PORT=587 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_FROM_EMAIL=no-reply@your-domain.example.com +SMTP_FROM_NAME=YourBrand +SMTP_REPLY_TO=hallo@your-domain.example.com +``` + +Verify the sending domain (`your-domain.example.com`) in the provider's +console before going live — most ESPs reject `From:` addresses on +unverified domains, and some put unverified senders into a sandbox mode +that only delivers to pre-allowlisted recipients. + +### B — Mailbox provider's SMTP (Zoho Mail, Fastmail, Gmail Workspace, Mailbox.org, …) + +Workable for low volume. The `From:` must usually match the authenticated +mailbox or a pre-configured alias. + +```env +SMTP_HOST=smtp..com +SMTP_PORT=587 +SMTP_USERNAME=no-reply@your-domain.example.com +SMTP_PASSWORD= # NOT the account login password +SMTP_FROM_EMAIL=no-reply@your-domain.example.com +SMTP_FROM_NAME=YourBrand +SMTP_REPLY_TO=hallo@your-domain.example.com +``` + +Mailbox providers typically require an **app-specific password** rather than +the account login — generate it in the provider's security settings. They +also enforce per-mailbox sending caps; a high-volume deployment will outrun +them. + +### C — Self-hosted relay (Postfix, Exim) on the same host + +Possible but rarely worth it: residential-grade IP reputation makes +deliverability fragile, and you're now operating the SMTP stack on top of +the application stack. + +```env +SMTP_HOST=127.0.0.1 +SMTP_PORT=25 # internal-only; not exposed externally +SMTP_USERNAME= # often empty for localhost relay +SMTP_PASSWORD= # often empty for localhost relay +SMTP_FROM_EMAIL=no-reply@your-domain.example.com +SMTP_FROM_NAME=YourBrand +``` + +Configure the local relay to forward via a reputable relay-host (your +ISP's smarthost, or a transactional ESP) so messages don't leave from a +residential IP block. Tighten port 25 to localhost-only at the OS firewall. + +--- + +## Verifying the configuration + +After setting the envs and restarting the container: + +```bash +# Trigger a real password-reset email to a test account. +curl -X POST https://your-domain.example.com/api/v1/auth/forgot-password \ + -H "Content-Type: application/json" \ + -d '{"email": "your-real-inbox@example.com"}' +# Expected: 200 OK +# Expected: an email arrives at the inbox within seconds. +``` + +If no mail arrives, check: + +1. **Application log** — the sender logs `send_email ok` (success) or + `send_email failed` (failure). The latter is logged at exception level + with full SMTP error details visible only in the server log; the HTTP + response stays generic so the SMTP details never leak to the client. +2. **DNS / SPF / DKIM / DMARC** — for ESPs and mailbox providers, the + sending domain must have valid SPF and DKIM records pointing at the + provider, plus a DMARC policy. Without alignment, Gmail and Outlook + silently drop or junk-folder the messages. +3. **Provider sandbox mode** — most ESPs ship new accounts in a sandbox + that only delivers to pre-allowlisted recipients until the domain is + verified. +4. **Outbound port** — confirm your hosting provider is not blocking + the chosen port. Use `nc -vz ` from inside the + container to verify reachability. + +--- + +## Privacy / DSGVO considerations + +Outbound SMTP introduces a **sub-processor**: the provider that relays the +mail can read the recipient address, the message body, and the IP of the +sending host. List the provider in your own +[`docs/sub-processors.md`](sub-processors.md)-equivalent disclosure and +update your privacy policy accordingly. + +The application sends **only** transactional content (auth flows + Stripe +receipts). It does not collect a marketing-consent flag, does not run +newsletter campaigns, and exposes no operator-side mailing surface. If you +add either, that's a new processing purpose under Art. 6 GDPR and needs its +own legal basis and disclosure. + +The token TTLs documented above (`30 min` for password reset, `7 d` for +email verification) are conservative; operators with stricter security +policies can lower them by editing +[`app/core/tokens.py`](https://github.com/MrChengLen/FileMorph/blob/main/app/core/tokens.py). + +--- + +## See also + +- [`docs/installation.md`](installation.md) — env-var overview during initial setup +- [`docs/self-hosting.md`](self-hosting.md) — production deployment & reverse-proxy +- [`docs/api-reference.md`](api-reference.md) — auth endpoint contract incl. token TTLs +- [`docs/sub-processors.md`](sub-processors.md) — what to disclose if you accept users +- [`.env.example`](https://github.com/MrChengLen/FileMorph/blob/main/.env.example) — every supported variable with a one-line description diff --git a/docs/installation.md b/docs/installation.md index adf6f8b..c82a9e7 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -86,6 +86,7 @@ Optional but recommended for production: - `APP_BASE_URL` — your public URL (used in email links and OG tags) - `STRIPE_*` envs — if you want billing - `SMTP_*` envs — if you want password-reset / email-verification flows + (see [`docs/email-setup.md`](email-setup.md) for a step-by-step walkthrough) `.env.example` documents every supported variable with a one-line description. diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 8301561..07d5fb6 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -347,9 +347,11 @@ All four endpoints write `auth.*` events to the audit-log hash chain. Outbound email uses the same `SMTP_*` configuration as password-reset; the FROM address, reply-to, and the body's "contact us" link are taken from `SMTP_FROM_EMAIL` / -`SMTP_REPLY_TO`. There are no hardcoded `@filemorph.io` addresses +`SMTP_REPLY_TO`. There are no hardcoded operator-domain addresses in the user-facing copy — self-hosters ship their own support -identity. +identity. See [`docs/email-setup.md`](email-setup.md) for the SMTP +walkthrough (provider options, port/TLS choice, sandbox-mode pitfalls, +DSGVO sub-processor disclosure). ### Updating diff --git a/tests/test_hook_allowlist_regression.py b/tests/test_hook_allowlist_regression.py index 4be73ba..8860ff1 100644 --- a/tests/test_hook_allowlist_regression.py +++ b/tests/test_hook_allowlist_regression.py @@ -110,6 +110,10 @@ def test_pre_commit_and_pre_push_regexes_stay_in_sync(hooks): "docs/api-usage-guide.md", "docs/self-hosting.md", "docs/dpa-template.md", + # Public Cloud-Edition email-setup walkthrough — placeholder + # .env-style SMTP credential lines would otherwise trip the + # SECRET_ASSIGN content scanner. + "docs/email-setup.md", # Hook scripts and CI workflow self-edits. ".githooks/pre-commit", ".githooks/pre-push", @@ -204,7 +208,6 @@ def test_forbidden_paths_does_not_block_public_artifacts(hooks, path): "path", [ "docs/admin-cockpit.md", - "docs/email-setup.md", "docs/open-tasks.md", "docs/filemorph-io-runbook.md", "docs/marketing-plan.md", @@ -238,6 +241,9 @@ def test_internal_paths_redirects_business_docs(hooks, path): "docs/sub-processors.md", "docs/dpa-template.md", "docs/gdpr-account-deletion-design.md", + # Reclassified from internal → public when the + # deployment-agnostic Self-Hoster walkthrough was added. + "docs/email-setup.md", ], ) def test_internal_paths_keeps_public_docs(hooks, path):