Skip to content

Storage and limits

Lumen stores three kinds of data:

KindWhereRequired
Mail metadata + bodiesD1Always
AttachmentsR2Optional (toggle)
Original .eml rawR2Optional (toggle)
Sessions, app configKVAlways

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
TierScope
max_attachment_bytesOne single attachment within a message
…_max_message_bytesOne whole message (text + html + all attachments)
…_max_user_storage_bytesEvery byte one user has stored across all folders
global_storage_bytesEvery 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_members row's max_storage_bytes (per-user) wins over the site-default default_max_user_storage_bytes.
  • A team_members row's max_message_bytes (per-user) wins over the site-default default_max_message_bytes.
  • The global_storage_bytes cap 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

KeyDefaultEffect
r2_enabledoffPersist attachments + raw .eml
store_raw_emailoffKeep the original RFC 822 bytes in R2
max_attachment_bytes10 MBPer-attachment cap. 0 = unlimited.
default_max_message_bytes25 MBPer-message default. Per-user override may apply.
default_max_user_storage_bytes100 MBPer-user total default. Per-user override may apply.
global_storage_bytes1 GBHard ceiling for the whole install. Cannot be overridden.
image_proxy_defaultonSite default for proxying remote images on read.
image_proxy_user_overrideonAllow 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 null in the DB).
  • Override with a specific MB value (stores that many bytes; 0 for 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 setReject so 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.