Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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/'
Expand Down
4 changes: 2 additions & 2 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
204 changes: 204 additions & 0 deletions docs/email-setup.md
Original file line number Diff line number Diff line change
@@ -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.<provider>.com
SMTP_PORT=587
SMTP_USERNAME=<api-username-or-token-key>
SMTP_PASSWORD=<api-token>
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.<provider>.com
SMTP_PORT=587
SMTP_USERNAME=no-reply@your-domain.example.com
SMTP_PASSWORD=<app-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 <smtp-host> <port>` 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
1 change: 1 addition & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 4 additions & 2 deletions docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion tests/test_hook_allowlist_regression.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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):
Expand Down
Loading