Skip to content

Commit aa043d7

Browse files
committed
docs(email): add public email-setup walkthrough + reclassify hook regex
Closes audit gap H6: until now, public docs mentioned `SMTP_*` envs but gave no guided walkthrough for picking a provider, choosing a port/TLS mode, or understanding what happens when SMTP is unconfigured. A self-hoster wiring up password-reset + email-verification had to piece it together from .env.example + scattered references in installation.md, self-hosting.md, and api-reference.md. Doc content (docs/email-setup.md, NEW) -------------------------------------- - Which features need SMTP (with their token TTLs and graceful-503 fallbacks when SMTP_HOST is empty) - Full env-var matrix with one-line meanings - Port 587 (STARTTLS) vs 465 (SMTPS) — incl. the egress-block pitfall some cloud hosts apply to 465 - Three deployment-agnostic walkthroughs: transactional ESP, mailbox-provider SMTP, self-hosted relay - Verification curl + four troubleshooting checks (app log, SPF/DKIM/DMARC, sandbox mode, port reachability) - DSGVO note: SMTP relay = sub-processor; update privacy disclosure The page is deployment-agnostic by design — no operator-specific provider, no hardcoded domain, every example value is a placeholder. Cross-links land in installation.md (env-var step) and self-hosting.md (auth-flows section), so the discovery path is "Cloud Edition setup -> SMTP -> read this doc". Also dropped a stray operator-domain reference in self-hosting.md that the soft scope-review flagged. Hook regex update (.githooks/pre-commit + pre-push) --------------------------------------------------- - ALLOW_RE: added docs/email-setup.md so the placeholder credential lines in the new doc don't trip the content scanner. - INTERNAL_PATHS: removed email-setup (was previously assumed to be ops-internal). The new public version supersedes that classification. Both regexes are kept in sync between pre-commit and pre-push (the H4 drift-check test asserts this). H4 regression test updated to match the new state (1 case added to allowlist, 1 removed from internal-paths, 1 added to public-docs negative test). 61 cases pass (was 60). Verification ------------ pytest tests/test_hook_allowlist_regression.py -> 61 passed pytest tests/ -> no regressions expected (doc-only + hook regex) All cross-link targets exist
1 parent d484744 commit aa043d7

6 files changed

Lines changed: 220 additions & 7 deletions

File tree

.githooks/pre-commit

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ FAIL=0
1313
# locale/ catalogs are mechanically extracted from the impressum/privacy/terms templates above —
1414
# they cannot avoid carrying the same address/email strings. Treating them as public is consistent
1515
# with the source templates being public.
16-
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)$'
16+
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)$'
1717

1818
# Personal/operational identifiers that should never land in public code.
1919
PATTERNS='lennart\.seidel@icloud\.com|lennart@filemorph\.io|Reetwerder|21029 Hamburg'
@@ -49,7 +49,7 @@ for f in $STAGED_FILES; do
4949
done
5050

5151
# Block staging of files that should be in docs-internal/.
52-
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$'
52+
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$'
5353

5454
# Block staging of files that are ops-only and must never appear in the public repo.
5555
FORBIDDEN_PATHS='^(compose\.prod\.yml|deploy\.sh|\.env\.production(\..*)?|CLAUDE\.md)$|^runbooks/|^docs-internal/'

.githooks/pre-push

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@ set -e
1515
ZERO=0000000000000000000000000000000000000000
1616

1717
# Same patterns as pre-commit — keep in sync.
18-
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)$'
18+
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)$'
1919
PATTERNS='lennart\.seidel@icloud\.com|lennart@filemorph\.io|Reetwerder|21029 Hamburg'
2020
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'
2121
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:]$]'
2222
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-----'
23-
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$'
23+
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$'
2424
FORBIDDEN_PATHS='^(compose\.prod\.yml|deploy\.sh|\.env\.production(\..*)?|CLAUDE\.md)$|^runbooks/|^docs-internal/'
2525

2626
FAIL=0

docs/email-setup.md

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
# Email Setup (Cloud Edition)
2+
3+
FileMorph's Cloud-overlay features — user registration with email verification,
4+
password reset, account-deletion confirmation, and (optionally) Stripe billing
5+
receipts — depend on outbound SMTP. This guide walks through configuring it for
6+
a self-hosted deployment.
7+
8+
The Community Edition (anonymous-tier conversion / compression with API keys)
9+
needs none of this. If you don't run the user-account features, leave the
10+
`SMTP_*` envs empty and skip this document.
11+
12+
---
13+
14+
## What needs SMTP
15+
16+
| Feature | Endpoint | What email carries |
17+
|---|---|---|
18+
| 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.** |
19+
| Password reset | `POST /api/v1/auth/forgot-password` | Single-use reset link. **30-minute TTL**, invalidated by the next password change (hash-version pin). |
20+
| Account-deletion confirmation | `DELETE /api/v1/auth/account` | Post-commit notification with the user-facing support contact. |
21+
| Billing receipts | Stripe Customer Portal | Stripe sends these directly via the customer portal — FileMorph itself only sends transactional auth mail. |
22+
23+
If `SMTP_HOST` is empty, every feature above degrades gracefully:
24+
25+
- `/forgot-password` and `/resend-verification` return `503 Service Unavailable`
26+
with a reason string the UI surfaces.
27+
- `/register` still creates the user — the verification email is fire-and-forget.
28+
The user can request a fresh link via `/resend-verification` once SMTP is wired.
29+
- `/auth/account` deletes the user even if the confirmation email cannot be sent;
30+
the deletion itself is the legally-binding action.
31+
32+
---
33+
34+
## Required environment variables
35+
36+
All documented in `.env.example`. The minimum for working transactional mail:
37+
38+
```env
39+
SMTP_HOST=smtp.example.com # your provider's SMTP relay
40+
SMTP_PORT=587 # 587 = STARTTLS, 465 = implicit SSL
41+
SMTP_USERNAME=no-reply@example.com # the auth user (often = FROM address)
42+
SMTP_PASSWORD=... # provider-issued app password / SMTP secret
43+
SMTP_FROM_EMAIL=no-reply@example.com # user-visible FROM address
44+
SMTP_FROM_NAME=YourBrand # display name in the From: header
45+
SMTP_REPLY_TO=hallo@example.com # optional; where users hit "reply"
46+
47+
APP_BASE_URL=https://your-domain.example.com # used to build link URLs
48+
```
49+
50+
`SMTP_FROM_EMAIL` and `SMTP_FROM_NAME` are what the recipient sees. There are
51+
no hard-coded `@`-domains anywhere in the user-visible copy — every link, From:
52+
address, and Reply-To: is taken from these variables. **Self-hosters ship their
53+
own support identity end-to-end.**
54+
55+
If `SMTP_FROM_EMAIL` is empty, the sender falls back to `SMTP_USERNAME`. Set
56+
both explicitly in production so the From: address never silently changes
57+
because someone rotated the SMTP login.
58+
59+
---
60+
61+
## Picking a port (TLS mode)
62+
63+
The TLS mode is chosen by port, no extra flag:
64+
65+
- `SMTP_PORT=465` — implicit SSL from the first byte (`SMTPS`). Used by some
66+
legacy providers; less common today.
67+
- `SMTP_PORT=587` — plain connect, then `STARTTLS` upgrade. The modern default.
68+
RFC 8314 § 3.3 recommends 587 for new deployments.
69+
70+
Some cloud providers (notably Hetzner Cloud at the time of writing) block
71+
outbound port 465 for new accounts as anti-abuse, while leaving 587 open. If
72+
your provider does the same, use 587 — it's the path of least friction. Open
73+
465 only if your SMTP provider requires it and your hosting provider allows
74+
the egress.
75+
76+
---
77+
78+
## Provider walk-throughs
79+
80+
The application talks to any RFC-compliant SMTP relay. Three common paths:
81+
82+
### A — Transactional ESP (Mailgun, Postmark, Brevo, ZeptoMail, Resend, …)
83+
84+
Best for production: deliverability is the ESP's full-time job, and you get
85+
DKIM/SPF/DMARC alignment plus a deliverability dashboard.
86+
87+
Typical setup (vendor-specific values shown as placeholders):
88+
89+
```env
90+
SMTP_HOST=smtp.<provider>.com
91+
SMTP_PORT=587
92+
SMTP_USERNAME=<api-username-or-token-key>
93+
SMTP_PASSWORD=<api-token>
94+
SMTP_FROM_EMAIL=no-reply@your-domain.example.com
95+
SMTP_FROM_NAME=YourBrand
96+
SMTP_REPLY_TO=hallo@your-domain.example.com
97+
```
98+
99+
Verify the sending domain (`your-domain.example.com`) in the provider's
100+
console before going live — most ESPs reject `From:` addresses on
101+
unverified domains, and some put unverified senders into a sandbox mode
102+
that only delivers to pre-allowlisted recipients.
103+
104+
### B — Mailbox provider's SMTP (Zoho Mail, Fastmail, Gmail Workspace, Mailbox.org, …)
105+
106+
Workable for low volume. The `From:` must usually match the authenticated
107+
mailbox or a pre-configured alias.
108+
109+
```env
110+
SMTP_HOST=smtp.<provider>.com
111+
SMTP_PORT=587
112+
SMTP_USERNAME=no-reply@your-domain.example.com
113+
SMTP_PASSWORD=<app-password> # NOT the account login password
114+
SMTP_FROM_EMAIL=no-reply@your-domain.example.com
115+
SMTP_FROM_NAME=YourBrand
116+
SMTP_REPLY_TO=hallo@your-domain.example.com
117+
```
118+
119+
Mailbox providers typically require an **app-specific password** rather than
120+
the account login — generate it in the provider's security settings. They
121+
also enforce per-mailbox sending caps; a high-volume deployment will outrun
122+
them.
123+
124+
### C — Self-hosted relay (Postfix, Exim) on the same host
125+
126+
Possible but rarely worth it: residential-grade IP reputation makes
127+
deliverability fragile, and you're now operating the SMTP stack on top of
128+
the application stack.
129+
130+
```env
131+
SMTP_HOST=127.0.0.1
132+
SMTP_PORT=25 # internal-only; not exposed externally
133+
SMTP_USERNAME= # often empty for localhost relay
134+
SMTP_PASSWORD= # often empty for localhost relay
135+
SMTP_FROM_EMAIL=no-reply@your-domain.example.com
136+
SMTP_FROM_NAME=YourBrand
137+
```
138+
139+
Configure the local relay to forward via a reputable relay-host (your
140+
ISP's smarthost, or a transactional ESP) so messages don't leave from a
141+
residential IP block. Tighten port 25 to localhost-only at the OS firewall.
142+
143+
---
144+
145+
## Verifying the configuration
146+
147+
After setting the envs and restarting the container:
148+
149+
```bash
150+
# Trigger a real password-reset email to a test account.
151+
curl -X POST https://your-domain.example.com/api/v1/auth/forgot-password \
152+
-H "Content-Type: application/json" \
153+
-d '{"email": "your-real-inbox@example.com"}'
154+
# Expected: 200 OK
155+
# Expected: an email arrives at the inbox within seconds.
156+
```
157+
158+
If no mail arrives, check:
159+
160+
1. **Application log** — the sender logs `send_email ok` (success) or
161+
`send_email failed` (failure). The latter is logged at exception level
162+
with full SMTP error details visible only in the server log; the HTTP
163+
response stays generic so the SMTP details never leak to the client.
164+
2. **DNS / SPF / DKIM / DMARC** — for ESPs and mailbox providers, the
165+
sending domain must have valid SPF and DKIM records pointing at the
166+
provider, plus a DMARC policy. Without alignment, Gmail and Outlook
167+
silently drop or junk-folder the messages.
168+
3. **Provider sandbox mode** — most ESPs ship new accounts in a sandbox
169+
that only delivers to pre-allowlisted recipients until the domain is
170+
verified.
171+
4. **Outbound port** — confirm your hosting provider is not blocking
172+
the chosen port. Use `nc -vz <smtp-host> <port>` from inside the
173+
container to verify reachability.
174+
175+
---
176+
177+
## Privacy / DSGVO considerations
178+
179+
Outbound SMTP introduces a **sub-processor**: the provider that relays the
180+
mail can read the recipient address, the message body, and the IP of the
181+
sending host. List the provider in your own
182+
[`docs/sub-processors.md`](sub-processors.md)-equivalent disclosure and
183+
update your privacy policy accordingly.
184+
185+
The application sends **only** transactional content (auth flows + Stripe
186+
receipts). It does not collect a marketing-consent flag, does not run
187+
newsletter campaigns, and exposes no operator-side mailing surface. If you
188+
add either, that's a new processing purpose under Art. 6 GDPR and needs its
189+
own legal basis and disclosure.
190+
191+
The token TTLs documented above (`30 min` for password reset, `7 d` for
192+
email verification) are conservative; operators with stricter security
193+
policies can lower them by editing
194+
[`app/core/tokens.py`](https://github.com/MrChengLen/FileMorph/blob/main/app/core/tokens.py).
195+
196+
---
197+
198+
## See also
199+
200+
- [`docs/installation.md`](installation.md) — env-var overview during initial setup
201+
- [`docs/self-hosting.md`](self-hosting.md) — production deployment & reverse-proxy
202+
- [`docs/api-reference.md`](api-reference.md) — auth endpoint contract incl. token TTLs
203+
- [`docs/sub-processors.md`](sub-processors.md) — what to disclose if you accept users
204+
- [`.env.example`](https://github.com/MrChengLen/FileMorph/blob/main/.env.example) — every supported variable with a one-line description

docs/installation.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ Optional but recommended for production:
8686
- `APP_BASE_URL` — your public URL (used in email links and OG tags)
8787
- `STRIPE_*` envs — if you want billing
8888
- `SMTP_*` envs — if you want password-reset / email-verification flows
89+
(see [`docs/email-setup.md`](email-setup.md) for a step-by-step walkthrough)
8990

9091
`.env.example` documents every supported variable with a one-line description.
9192

docs/self-hosting.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -347,9 +347,11 @@ All four endpoints write `auth.*` events to the audit-log hash
347347
chain. Outbound email uses the same `SMTP_*` configuration as
348348
password-reset; the FROM address, reply-to, and the body's
349349
"contact us" link are taken from `SMTP_FROM_EMAIL` /
350-
`SMTP_REPLY_TO`. There are no hardcoded `@filemorph.io` addresses
350+
`SMTP_REPLY_TO`. There are no hardcoded operator-domain addresses
351351
in the user-facing copy — self-hosters ship their own support
352-
identity.
352+
identity. See [`docs/email-setup.md`](email-setup.md) for the SMTP
353+
walkthrough (provider options, port/TLS choice, sandbox-mode pitfalls,
354+
DSGVO sub-processor disclosure).
353355

354356
### Updating
355357

tests/test_hook_allowlist_regression.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,10 @@ def test_pre_commit_and_pre_push_regexes_stay_in_sync(hooks):
110110
"docs/api-usage-guide.md",
111111
"docs/self-hosting.md",
112112
"docs/dpa-template.md",
113+
# Public Cloud-Edition email-setup walkthrough — placeholder
114+
# .env-style SMTP credential lines would otherwise trip the
115+
# SECRET_ASSIGN content scanner.
116+
"docs/email-setup.md",
113117
# Hook scripts and CI workflow self-edits.
114118
".githooks/pre-commit",
115119
".githooks/pre-push",
@@ -204,7 +208,6 @@ def test_forbidden_paths_does_not_block_public_artifacts(hooks, path):
204208
"path",
205209
[
206210
"docs/admin-cockpit.md",
207-
"docs/email-setup.md",
208211
"docs/open-tasks.md",
209212
"docs/filemorph-io-runbook.md",
210213
"docs/marketing-plan.md",
@@ -238,6 +241,9 @@ def test_internal_paths_redirects_business_docs(hooks, path):
238241
"docs/sub-processors.md",
239242
"docs/dpa-template.md",
240243
"docs/gdpr-account-deletion-design.md",
244+
# Reclassified from internal → public when the
245+
# deployment-agnostic Self-Hoster walkthrough was added.
246+
"docs/email-setup.md",
241247
],
242248
)
243249
def test_internal_paths_keeps_public_docs(hooks, path):

0 commit comments

Comments
 (0)