Letterbox provides privacy protection when loading remote images in emails. The app uses a MASQUE proxy through Cloudflare WARP to hide your IP address from image servers.
- Remote images (HTTP and HTTPS URLs) are blocked by default to protect privacy.
- When an email contains remote images, a banner displays "Remote images are hidden."
- Tap "Show" to load images for the current email session.
When images load through the proxy:
- Your IP address remains hidden from the email sender.
- Tracking headers (Referer, User-Agent) are stripped.
- Cookies are never sent to image servers.
Access Settings from the main screen menu:
- Default: OFF
- When enabled, remote images load automatically in all emails.
- Images are still proxied for privacy.
- Default: ON
- When enabled, all remote images load through the privacy proxy.
- When disabled, images load directly from their source (exposes your IP address).
Settings carries a standing disclosure beside the privacy-proxy switch: loading a remote image sends the request through the Cloudflare WARP tunnel, subject to Cloudflare's Terms of Service, with a link to read them.
There is no separate acceptance step. Loading an image is itself the opt-in — remote images are blocked by default and require either a per-message "Show images" tap or an explicit setting — so a second confirmation adds a condition without adding a choice.
It also caused a total failure. A cloudflareTermsAccepted flag was AND-ed into
the WebView's network gate, while the "Show images" banner that sets the other
half tested only itself. Tapping the banner retired it and left the gate shut,
so every image failed with no surface anywhere explaining why, and anyone who
had onboarded before the flag existed had it false permanently. The gate is now
a single value, RemoteImagePolicy, that both the banner and the WebView read —
an offer the app will not honour is unrepresentable.
The proxy implementation uses a MASQUE tunnel (CONNECT-IP over HTTP/3) through Cloudflare WARP:
┌───────────────┐ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐
│ Image URL │ ──▶ │ letterbox │ ──▶ │ Cloudflare │ ──▶ │ Image │
│ from Email │ │ proxy │ │ WARP │ │ Server │
└───────────────┘ └──────────────┘ └──────────────┘ └─────────────┘
│
┌─────────┴─────────┐
│ MASQUE / QUIC │
│ (UDP 443, TLS) │
└───────────────────┘
| Component | Purpose |
|---|---|
| usque-core (quiche) | MASQUE CONNECT-IP over HTTP/3 |
| smoltcp | Userspace TCP/IP stack |
| rustls | TLS 1.3 for HTTPS connections |
| LRU cache | In-memory caching of fetched images |
- Supports all image formats including SVG and WebP.
- Provides consistent performance with direct tunnel control.
- Enables detailed error messages for debugging.
- Optimizes for emails with many images through parallel fetching.
- IP Masking: Your IP address is not visible to the email sender.
- Header Stripping: Tracking headers are removed.
- Cookie Blocking: Cookies are never sent or stored.
- No JavaScript: The WebView keeps JavaScript disabled for security.
- Inline images embedded in the email always display.
- These are not affected by the remote image settings.
- No network access is required for inline images.
The email WebView provides conventional link interaction:
- HTTP/HTTPS links: Open in the default browser app
- mailto: links: Open in the default email client
Long-pressing a link or image shows a context menu with options:
- Links: "Open link" or "Copy link address"
- Images: "Open image" or "Copy image URL"
This provides a familiar user experience while maintaining security by opening external content outside the app sandbox.
The WebView remains sandboxed with:
- JavaScript disabled
- File access disabled
- Network loads intercepted and proxied through WARP
Storage Access Framework (SAF) permissions are persisted when opening files. Only specific MIME types are accepted by the file picker.
When the native Rust library is unavailable or encounters an error:
- The original HTML displays without modification.
- The app does not crash; errors are caught and handled gracefully.
- Inline (cid:) images continue to work normally.
- If proxy fails, images display an error placeholder.
The app requires the following permissions:
| Permission | Purpose |
|---|---|
INTERNET |
Required for the MASQUE tunnel to communicate with Cloudflare WARP endpoints |
Why INTERNET permission is needed: The privacy proxy creates a MASQUE tunnel over QUIC (UDP/443) to encrypt traffic and route it through Cloudflare. Without INTERNET permission, the proxy cannot establish network connections.
Privacy remains protected because:
- Your IP address is hidden behind Cloudflare's infrastructure
- No tracking headers are sent
- Cookies are blocked
- The proxy only fetches images - it doesn't browse or track
- UserPreferencesRepository: Persists settings using Jetpack DataStore.
- ImageProxyService: Kotlin service that wraps the Rust FFI for image fetching.
- letterbox-proxy: Rust crate that handles image fetching through WARP tunnel.
- EmailDetailScreen: Displays privacy banner and controls image loading.
- EmailWebView: Intercepts HTTP/HTTPS requests and routes through the proxy.
Run the Rust proxy tests:
cargo test --manifest-path rust/letterbox-proxy/Cargo.tomlRun the Kotlin unit tests:
./gradlew :app:testProdDebugUnitTestRun the Android instrumented tests:
./gradlew :app:connectedProdDebugAndroidTestTest coverage includes:
- URL validation and content type checking
- WARP configuration and persistence
- MASQUE tunnel creation
- Cache behavior
- Error handling scenarios
- Remote image banner display and interaction
RemoteImagePolicy: the banner is offered exactly when a tap would unblock loading, checked over the whole input space rather than one path- Settings persistence across app restarts
RemoteImageWarpE2ETest calls ImageProxyService.fetchImage directly, so it
proves the tunnel and the proxy work but never crosses the WebView gate. The
2026-07-30 failure lived exactly there — in the predicate deciding whether the
WebView was allowed to ask — so that test passed throughout while no image in
any email loaded.
RemoteImagePolicyTest now covers the decision as an algebra, which is the part
that was wrong. What remains untested end to end is the wiring: that
EmailDetailScreen passes policy.allowsNetworkLoads into EmailWebView, and
that EmailWebView turns it into blockNetworkLoads = !allow. An instrumented
test that taps "Show images" and asserts webView.settings.blockNetworkLoads
became false would close it, and is the test whose absence let this ship.