Storage and limits
Lumen stores three kinds of data:
| Kind | Where | Required |
|---|---|---|
| Mail metadata + bodies | D1 | Always |
| Attachments | R2 | Optional (toggle) |
Original .eml raw | R2 | Optional (toggle) |
| Sessions, app config | KV | Always |
R2 is opt-in. Until an admin enables it, Lumen runs in a metadata-only mode: incoming attachments are listed in the message UI but the bytes are discarded; the message body (text + sanitized HTML) still lands in D1.
The four storage caps
Lumen enforces caps at four tiers; every storage operation has to clear all of them. From narrowest to broadest:
single attachment ≤ per-message ≤ per-user total ≤ global total| Tier | Scope |
|---|---|
max_attachment_bytes | One single attachment within a message |
…_max_message_bytes | One whole message (text + html + all attachments) |
…_max_user_storage_bytes | Every byte one user has stored across all folders |
global_storage_bytes | Every byte across the whole Lumen install |
The two middle caps are split into a site default and an optional per-user override. The hierarchy is:
- A
team_membersrow'smax_storage_bytes(per-user) wins over the site-defaultdefault_max_user_storage_bytes. - A
team_membersrow'smax_message_bytes(per-user) wins over the site-defaultdefault_max_message_bytes. - The
global_storage_bytescap is site-wide and not overridable.
null in a per-user column means "inherit the site default". The literal value 0 means "explicitly unlimited at this tier" — an explicit override to remove a cap. So null and 0 are not the same thing.
Configuration keys
| Key | Default | Effect |
|---|---|---|
r2_enabled | off | Persist attachments + raw .eml |
store_raw_email | off | Keep the original RFC 822 bytes in R2 |
max_attachment_bytes | 10 MB | Per-attachment cap. 0 = unlimited. |
default_max_message_bytes | 25 MB | Per-message default. Per-user override may apply. |
default_max_user_storage_bytes | 100 MB | Per-user total default. Per-user override may apply. |
global_storage_bytes | 1 GB | Hard ceiling for the whole install. Cannot be overridden. |
image_proxy_default | on | Site default for proxying remote images on read. |
image_proxy_user_override | on | Allow members to pick Always proxy/Never proxy. |
The InitPage prompts for all of these at first boot. Owners and co-owners can edit them later through PUT /api/init/config.
Per-user overrides
Owners and co-owners can override the per-user and per-message caps for a specific member from Settings → Members → ⋯ → Storage limits. The dialog lets you switch each cap between:
- Inherit site default (stores
nullin the DB). - Override with a specific MB value (stores that many bytes;
0for explicitly unlimited).
The site-wide hard ceiling (global_storage_bytes) always applies on top, so even an unlimited per-user override can't push the install past the global cap.
Behavior when caps are hit
- Per-message would-overflow on inbound: the Cloudflare Email Routing handler calls
setRejectso the sending server gets an SMTP rejection; nothing is written to D1 or R2. - Per-user total would-overflow on inbound: same — message is rejected back to the sending server. (The user's existing storage is untouched.)
- Global would-overflow on inbound: same. This is the bill-saver — once you hit the cap, ALL inbound storage operations bounce until someone frees space or raises the cap.
- On outbound (
POST /api/send): the request returns HTTP 413 (per- message) or 507 (per-user / global) before any mail is sent. - On draft autosave (
POST /api/drafts): same status codes; the composer surfaces the error inline.
Trash counts against quota. Empty trash to reclaim space, or just delete specific messages from inside trash for a hard delete.
How storage is tracked
Each messages row carries a storage_bytes column equal to the bytes this message contributes (text + sanitized HTML + attachment bytes that were actually written). The user's total lives in team_members.storage_bytes. Inserts/deletes update both in lockstep — including draft autosaves, sent messages, hard-deletes, and empty-trash. The global total is computed at request time from SUM(team_members.storage_bytes).
Image proxy
Inline <img src=...> tags in incoming mail can leak the recipient's IP address and approximate read time to the sender. With the image proxy on, every external http(s) <img> is rewritten on read to /api/proxy/image?url=…, and the Worker fetches the bytes server-side with these defenses:
- HTTP/HTTPS only (no
file:,gopher:, …). - Hostname blocklist for private/loopback/link-local IPv4 + IPv6 ranges,
localhost,*.local,*.internal,metadata.*, etc. - Manual redirect handling, max 3 hops, each hop re-validated.
- Response timeout (8 s), Content-Length and streaming size cap (10 MB).
- Content-Type must start with
image/.nosniff+ same-origin CORP on responses. - No cookies, no user agent leak.
The site default applies to every member unless they pick Always proxy or Never proxy in Settings → Profile, which only works when image_proxy_user_override is enabled.