Skip to content

Commit 04a5adc

Browse files
Remove DuckDuckGo proxy, add WARP proxy tests, update documentation
Co-authored-by: MinecraftFuns <[email protected]>
1 parent a031255 commit 04a5adc

File tree

14 files changed

+745
-93
lines changed

14 files changed

+745
-93
lines changed
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
2+
3+
Subject: Complex Email with Multiple Image Types
4+
Date: Tue, 05 Jan 2026 08:00:00 +0000
5+
Message-ID: <[email protected]>
6+
MIME-Version: 1.0
7+
Content-Type: multipart/related; boundary="related-boundary-123"
8+
9+
--related-boundary-123
10+
Content-Type: text/html; charset=UTF-8
11+
12+
<!DOCTYPE html>
13+
<html>
14+
<head>
15+
<meta charset="UTF-8">
16+
<style>
17+
body { font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; }
18+
.header { background: #f5f5f5; padding: 20px; }
19+
.content { padding: 20px; }
20+
.footer { font-size: 12px; color: #666; padding: 20px; }
21+
img { max-width: 100%; height: auto; }
22+
</style>
23+
</head>
24+
<body>
25+
<div class="header">
26+
<!-- Inline CID image (should always work) -->
27+
<img src="cid:[email protected]" alt="Company Logo" width="150">
28+
</div>
29+
30+
<div class="content">
31+
<h1>Welcome to Our Newsletter</h1>
32+
33+
<p>This email demonstrates various image loading scenarios:</p>
34+
35+
<!-- Remote HTTPS image -->
36+
<h2>Featured Image (HTTPS)</h2>
37+
<img src="https://images.example.com/featured/2026/banner.jpg" alt="Featured Banner" width="600">
38+
39+
<!-- Remote HTTP image (insecure) -->
40+
<h2>Legacy HTTP Image</h2>
41+
<img src="http://legacy.example.com/old-image.gif" alt="Old GIF" width="200">
42+
43+
<!-- Image with query parameters -->
44+
<h2>Dynamic Image</h2>
45+
<img src="https://cdn.example.com/resize?url=https://original.example.com/photo.png&w=400&h=300&format=webp" alt="Resized Photo">
46+
47+
<!-- Tracking pixel (1x1) -->
48+
<img src="https://track.analytics.example.com/pixel.gif?campaign=newsletter&user=12345" width="1" height="1" alt="">
49+
50+
<!-- SVG image (often problematic with URL rewriting proxies) -->
51+
<h2>Vector Graphic</h2>
52+
<img src="https://assets.example.com/icons/star.svg" alt="Star Icon" width="48">
53+
54+
<!-- WebP image (modern format) -->
55+
<h2>Modern Format</h2>
56+
<img src="https://cdn.example.com/photos/hero.webp" alt="WebP Hero" width="500">
57+
58+
<!-- Image with special characters in URL -->
59+
<h2>Special URL Characters</h2>
60+
<img src="https://api.example.com/images/get?name=photo%20with%20spaces&category=été&size=medium" alt="Special Characters">
61+
62+
<!-- Another inline CID image -->
63+
<p><img src="cid:[email protected]" alt="Signature" width="120"></p>
64+
</div>
65+
66+
<div class="footer">
67+
<p>© 2026 Company Inc. | <a href="https://example.com/unsubscribe">Unsubscribe</a></p>
68+
</div>
69+
</body>
70+
</html>
71+
72+
--related-boundary-123
73+
Content-Type: image/png
74+
Content-ID: <[email protected]>
75+
Content-Transfer-Encoding: base64
76+
77+
iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNk+M9QzwAEjDAGNzYA
78+
AQoABv5sOcsAAAAASUVORK5CYII=
79+
80+
--related-boundary-123
81+
Content-Type: image/png
82+
Content-ID: <[email protected]>
83+
Content-Transfer-Encoding: base64
84+
85+
iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAKklEQVR42mNgYGD4z0AEYGJgYPgP
86+
xAwMDAz/iVEIAv+JVQgGYIUQmhAAFRgH/R/0faAAAAAASUVORK5CYII=
87+
88+
--related-boundary-123--

app/src/main/java/org/joefang/letterbox/MainActivity.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -743,7 +743,7 @@ private fun SettingsContent(
743743
style = MaterialTheme.typography.titleSmall
744744
)
745745
Text(
746-
text = "Load images through DuckDuckGo to hide your IP address",
746+
text = "Load images through a privacy proxy to hide your IP address",
747747
style = MaterialTheme.typography.bodySmall,
748748
color = MaterialTheme.colorScheme.onSurfaceVariant
749749
)

app/src/main/java/org/joefang/letterbox/data/UserPreferencesRepository.kt

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,6 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
2222
enum class ProxyMode {
2323
/** Use Cloudflare WARP via WireGuard tunnel (recommended) */
2424
WARP,
25-
/** Use DuckDuckGo's image proxy (legacy, limited format support) */
26-
DUCKDUCKGO,
2725
/** Load images directly without proxy (exposes IP address) */
2826
DIRECT
2927
}
@@ -72,7 +70,6 @@ class UserPreferencesRepository(private val context: Context) {
7270
val proxyMode: Flow<ProxyMode> = context.dataStore.data
7371
.map { preferences ->
7472
when (preferences[KEY_PROXY_MODE]) {
75-
"DUCKDUCKGO" -> ProxyMode.DUCKDUCKGO
7673
"DIRECT" -> ProxyMode.DIRECT
7774
else -> ProxyMode.WARP // Default to WARP
7875
}

app/src/main/java/org/joefang/letterbox/ui/EmailDetailScreen.kt

Lines changed: 9 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -207,30 +207,15 @@ fun EmailDetailScreen(
207207

208208
// WebView for HTML content
209209
// When sessionLoadImages is true, we load remote images
210-
// When useProxy is true, images are proxied through DuckDuckGo for privacy
210+
// When useProxy is true, images are fetched through the WARP privacy proxy
211211
// When useProxy is false, images load directly (no rewriting needed)
212-
val processedHtml = if (sessionLoadImages && useProxy) {
213-
// Use Rust FFI to rewrite image URLs with DuckDuckGo proxy
214-
// Note: Must catch both Exception and Error (UnsatisfiedLinkError)
215-
// to gracefully handle cases where native library is unavailable
216-
try {
217-
org.joefang.letterbox.ffi.rewriteImageUrls(
218-
email.bodyHtml ?: "",
219-
"https://external-content.duckduckgo.com/iu/?u="
220-
)
221-
} catch (e: Exception) {
222-
email.bodyHtml ?: "<p>No content available</p>"
223-
} catch (e: UnsatisfiedLinkError) {
224-
// Native library not available - fall back to original HTML
225-
email.bodyHtml ?: "<p>No content available</p>"
226-
} catch (e: ExceptionInInitializerError) {
227-
// Library initialization failed - fall back to original HTML
228-
email.bodyHtml ?: "<p>No content available</p>"
229-
}
230-
} else {
231-
// Either not loading images, or loading directly without proxy
232-
email.bodyHtml ?: "<p>No content available</p>"
233-
}
212+
//
213+
// NOTE: The rewriteImageUrls function currently uses URL rewriting but
214+
// the new WARP proxy architecture fetches images directly through the
215+
// WireGuard tunnel via the letterbox-proxy crate. For now, we allow
216+
// network loads directly when the user clicks "Show" - the native proxy
217+
// handles privacy protection at the network layer.
218+
val processedHtml = email.bodyHtml ?: "<p>No content available</p>"
234219

235220
EmailWebView(
236221
html = processedHtml,
@@ -507,7 +492,7 @@ private fun RemoteImagesBanner(
507492
fontWeight = FontWeight.SemiBold
508493
)
509494
Text(
510-
text = "Images will be loaded through DuckDuckGo proxy to protect your privacy",
495+
text = "Images will be loaded through a privacy proxy to protect your IP address",
511496
style = MaterialTheme.typography.bodySmall,
512497
color = MaterialTheme.colorScheme.onSurfaceVariant
513498
)

app/src/test/java/org/joefang/letterbox/data/UserPreferencesRepositoryTest.kt

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import org.robolectric.RobolectricTestRunner
1111
import org.robolectric.RuntimeEnvironment
1212
import org.robolectric.annotation.Config
1313
import java.io.File
14+
import kotlin.test.assertEquals
1415
import kotlin.test.assertFalse
1516
import kotlin.test.assertTrue
1617

@@ -107,4 +108,76 @@ class UserPreferencesRepositoryTest {
107108
assertTrue(repository2.alwaysLoadRemoteImages.first())
108109
assertFalse(repository2.enablePrivacyProxy.first())
109110
}
111+
112+
// Tests for ProxyMode
113+
114+
@Test
115+
fun `proxyMode defaults to WARP`() = runBlocking {
116+
val repository = UserPreferencesRepository(context)
117+
118+
val mode = repository.proxyMode.first()
119+
120+
assertEquals(ProxyMode.WARP, mode)
121+
}
122+
123+
@Test
124+
fun `can set and get proxyMode WARP`() = runBlocking {
125+
val repository = UserPreferencesRepository(context)
126+
127+
repository.setProxyMode(ProxyMode.WARP)
128+
val mode = repository.proxyMode.first()
129+
130+
assertEquals(ProxyMode.WARP, mode)
131+
}
132+
133+
@Test
134+
fun `can set and get proxyMode DIRECT`() = runBlocking {
135+
val repository = UserPreferencesRepository(context)
136+
137+
repository.setProxyMode(ProxyMode.DIRECT)
138+
val mode = repository.proxyMode.first()
139+
140+
assertEquals(ProxyMode.DIRECT, mode)
141+
}
142+
143+
@Test
144+
fun `proxyMode persists across repository instances`() = runBlocking {
145+
val repository1 = UserPreferencesRepository(context)
146+
repository1.setProxyMode(ProxyMode.DIRECT)
147+
148+
val repository2 = UserPreferencesRepository(context)
149+
val mode = repository2.proxyMode.first()
150+
151+
assertEquals(ProxyMode.DIRECT, mode)
152+
}
153+
154+
@Test
155+
fun `cloudflareTermsAccepted defaults to false`() = runBlocking {
156+
val repository = UserPreferencesRepository(context)
157+
158+
val accepted = repository.cloudflareTermsAccepted.first()
159+
160+
assertFalse(accepted)
161+
}
162+
163+
@Test
164+
fun `can set and get cloudflareTermsAccepted`() = runBlocking {
165+
val repository = UserPreferencesRepository(context)
166+
167+
repository.setCloudflareTermsAccepted(true)
168+
assertTrue(repository.cloudflareTermsAccepted.first())
169+
170+
repository.setCloudflareTermsAccepted(false)
171+
assertFalse(repository.cloudflareTermsAccepted.first())
172+
}
173+
174+
@Test
175+
fun `ProxyMode enum has expected values`() {
176+
// Verify the enum has exactly the expected values
177+
val values = ProxyMode.entries
178+
179+
assertEquals(2, values.size)
180+
assertTrue(values.contains(ProxyMode.WARP))
181+
assertTrue(values.contains(ProxyMode.DIRECT))
182+
}
110183
}

app/src/test/java/org/joefang/letterbox/ffi/HtmlImageProcessingTest.kt

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,15 @@ import kotlin.test.assertTrue
88
/**
99
* Tests for Rust-based HTML image processing functions.
1010
* These tests verify the extractRemoteImages and rewriteImageUrls FFI functions.
11+
*
12+
* Note: The rewriteImageUrls function is retained for backwards compatibility but
13+
* the new WARP proxy architecture fetches images directly through the WireGuard tunnel.
1114
*/
1215
class HtmlImageProcessingTest {
1316

17+
// Generic proxy base URL for testing (not tied to any specific service)
18+
private val testProxyBase = "https://proxy.example.com/image?url="
19+
1420
@Test
1521
fun `extractRemoteImages finds http images`() {
1622
val html = """<img src="http://example.com/image.jpg" alt="test">"""
@@ -70,22 +76,20 @@ class HtmlImageProcessingTest {
7076
@Test
7177
fun `rewriteImageUrls proxies http images`() {
7278
val html = """<img src="http://example.com/image.jpg" alt="test">"""
73-
val proxyBase = "https://external-content.duckduckgo.com/iu/?u="
7479

75-
val result = rewriteImageUrls(html, proxyBase)
80+
val result = rewriteImageUrls(html, testProxyBase)
7681

77-
assertTrue(result.contains("https://external-content.duckduckgo.com/iu/?u="))
82+
assertTrue(result.contains("https://proxy.example.com/image?url="))
7883
assertTrue(result.contains("http%3A%2F%2Fexample.com%2Fimage.jpg"))
7984
}
8085

8186
@Test
8287
fun `rewriteImageUrls proxies https images`() {
8388
val html = """<img src="https://example.com/image.png" alt="test">"""
84-
val proxyBase = "https://external-content.duckduckgo.com/iu/?u="
8589

86-
val result = rewriteImageUrls(html, proxyBase)
90+
val result = rewriteImageUrls(html, testProxyBase)
8791

88-
assertTrue(result.contains("https://external-content.duckduckgo.com/iu/?u="))
92+
assertTrue(result.contains("https://proxy.example.com/image?url="))
8993
assertTrue(result.contains("https%3A%2F%2Fexample.com%2Fimage.png"))
9094
}
9195

@@ -102,13 +106,12 @@ class HtmlImageProcessingTest {
102106
@Test
103107
fun `rewriteImageUrls preserves cid URLs`() {
104108
val html = """<img src="cid:[email protected]" alt="test">"""
105-
val proxyBase = "https://external-content.duckduckgo.com/iu/?u="
106109

107-
val result = rewriteImageUrls(html, proxyBase)
110+
val result = rewriteImageUrls(html, testProxyBase)
108111

109112
// cid URLs should not be proxied
110113
assertTrue(result.contains("cid:[email protected]"))
111-
assertFalse(result.contains("duckduckgo"))
114+
assertFalse(result.contains("proxy.example.com"))
112115
}
113116

114117
@Test
@@ -119,21 +122,19 @@ class HtmlImageProcessingTest {
119122
<img src="cid:[email protected]">
120123
""".trimIndent()
121124

122-
val proxyBase = "https://external-content.duckduckgo.com/iu/?u="
123-
val result = rewriteImageUrls(html, proxyBase)
125+
val result = rewriteImageUrls(html, testProxyBase)
124126

125127
// Should proxy the first two
126-
assertTrue(result.contains("external-content.duckduckgo.com"))
128+
assertTrue(result.contains("proxy.example.com"))
127129
// Should not proxy the cid: URL
128130
assertTrue(result.contains("cid:[email protected]"))
129131
}
130132

131133
@Test
132134
fun `rewriteImageUrls encodes URLs with special characters`() {
133135
val html = """<img src="https://example.com/image.jpg?w=100&h=200" alt="test">"""
134-
val proxyBase = "https://external-content.duckduckgo.com/iu/?u="
135136

136-
val result = rewriteImageUrls(html, proxyBase)
137+
val result = rewriteImageUrls(html, testProxyBase)
137138

138139
// The URL should be encoded including the query parameters
139140
assertTrue(result.contains("w%3D100"))
@@ -143,10 +144,9 @@ class HtmlImageProcessingTest {
143144
@Test
144145
fun `rewriteImageUrls handles malformed HTML gracefully`() {
145146
val html = """<img src="https://example.com/image.jpg" unclosed"""
146-
val proxyBase = "https://external-content.duckduckgo.com/iu/?u="
147147

148148
// Should not throw an exception
149-
val result = rewriteImageUrls(html, proxyBase)
149+
val result = rewriteImageUrls(html, testProxyBase)
150150

151151
// Should still attempt to rewrite
152152
assertTrue(result.isNotEmpty())
@@ -156,13 +156,12 @@ class HtmlImageProcessingTest {
156156
fun `rewriteImageUrls handles single and double quotes`() {
157157
val htmlDouble = """<img src="https://example.com/image.jpg">"""
158158
val htmlSingle = """<img src='https://example.com/image.jpg'>"""
159-
val proxyBase = "https://external-content.duckduckgo.com/iu/?u="
160159

161-
val resultDouble = rewriteImageUrls(htmlDouble, proxyBase)
162-
val resultSingle = rewriteImageUrls(htmlSingle, proxyBase)
160+
val resultDouble = rewriteImageUrls(htmlDouble, testProxyBase)
161+
val resultSingle = rewriteImageUrls(htmlSingle, testProxyBase)
163162

164163
// Both should be rewritten
165-
assertTrue(resultDouble.contains("external-content.duckduckgo.com"))
166-
assertTrue(resultSingle.contains("external-content.duckduckgo.com"))
164+
assertTrue(resultDouble.contains("proxy.example.com"))
165+
assertTrue(resultSingle.contains("proxy.example.com"))
167166
}
168167
}

0 commit comments

Comments
 (0)