Skip to main content

max / goingson

Fix email and OAuth handling: IMAP UIDs, URL decoding, is_read flag - Skip IMAP messages with no UID instead of falling back to 0 - Use saturating_add for UID search range - Truncate from unclosed script/style tags in HTML stripping - Preserve is_read flag in UnifiedEmail conversion from ParsedEmail - URL-decode OAuth callback code and state parameters - Fix custom URL decoder to handle multi-byte UTF-8 correctly - Increase OAuth callback buffer from 4 KB to 16 KB - Use expect() instead of unwrap_or_default() for JMAP HTTP client build Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-26 19:39 UTC
Commit: 78ac2b1a27d108b3838e78b800e37ae5e3ff64d5
Parent: d10b4b7
4 files changed, +38 insertions, -27 deletions
@@ -208,10 +208,13 @@ impl ImapClient {
208 208 continue;
209 209 }
210 210 };
211 - let uid = message.uid.unwrap_or_else(|| {
212 - tracing::warn!(folder = %folder_name, "IMAP message missing UID, using 0");
213 - 0
214 - });
211 + let uid = match message.uid {
212 + Some(u) => u,
213 + None => {
214 + tracing::warn!(folder = %folder_name, "IMAP message missing UID, skipping");
215 + continue;
216 + }
217 + };
215 218
216 219 // Check if \Seen flag is present (email has been read)
217 220 let is_read = message.flags().any(|f| {
@@ -338,7 +341,7 @@ impl ImapClient {
338 341
339 342 let uids: Vec<u32> = if use_uid {
340 343 let state = sync_state.unwrap();
341 - let search_query = format!("UID {}:*", state.last_seen_uid + 1);
344 + let search_query = format!("UID {}:*", state.last_seen_uid.saturating_add(1));
342 345 debug.push(format!("uid_search: {}", search_query));
343 346
344 347 let search_result = session
@@ -409,14 +412,15 @@ impl ImapClient {
409 412 continue;
410 413 }
411 414 };
412 - let uid = message.uid.unwrap_or_else(|| {
413 - tracing::warn!(folder = %folder_name, "IMAP message missing UID, using 0");
414 - 0
415 - });
415 + let uid = match message.uid {
416 + Some(u) => u,
417 + None => {
418 + tracing::warn!(folder = %folder_name, "IMAP message missing UID, skipping");
419 + continue;
420 + }
421 + };
416 422
417 - if uid > 0 {
418 - max_uid = Some(max_uid.map_or(uid, |m: u32| m.max(uid)));
419 - }
423 + max_uid = Some(max_uid.map_or(uid, |m: u32| m.max(uid)));
420 424
421 425 let is_read = message.flags().any(|f| matches!(f, async_imap::types::Flag::Seen));
422 426
@@ -674,6 +678,8 @@ impl ImapClient {
674 678 if let Some(end) = text.to_lowercase()[start..].find("</script>") {
675 679 text = format!("{}{}", &text[..start], &text[start + end + 9..]);
676 680 } else {
681 + // Unclosed script tag — remove everything from here to end
682 + text.truncate(start);
677 683 break;
678 684 }
679 685 }
@@ -683,6 +689,7 @@ impl ImapClient {
683 689 if let Some(end) = text.to_lowercase()[start..].find("</style>") {
684 690 text = format!("{}{}", &text[..start], &text[start + end + 8..]);
685 691 } else {
692 + text.truncate(start);
686 693 break;
687 694 }
688 695 }
@@ -48,7 +48,7 @@ impl From<ParsedEmail> for UnifiedEmail {
48 48 subject: email.subject,
49 49 body: email.body,
50 50 date: email.date,
51 - is_read: false, // IMAP fetch doesn't include flags
51 + is_read: email.is_read,
52 52 }
53 53 }
54 54 }
@@ -27,7 +27,7 @@ impl JmapClient {
27 27 .timeout(Duration::from_secs(30))
28 28 .connect_timeout(Duration::from_secs(10))
29 29 .build()
30 - .unwrap_or_default();
30 + .expect("failed to build HTTP client");
31 31 Self {
32 32 client,
33 33 access_token: access_token.into(),
@@ -46,7 +46,7 @@ impl JmapClient {
46 46 .timeout(Duration::from_secs(30))
47 47 .connect_timeout(Duration::from_secs(10))
48 48 .build()
49 - .unwrap_or_default();
49 + .expect("failed to build HTTP client");
50 50 Self {
51 51 client,
52 52 access_token: access_token.into(),
@@ -155,7 +155,7 @@ impl OAuthCallbackServer {
155 155 stored: &Arc<Mutex<StoredCallback>>,
156 156 _callback_received: bool,
157 157 ) -> Option<Result<CallbackResult, CallbackError>> {
158 - let mut buffer = [0; 4096];
158 + let mut buffer = [0; 16384];
159 159 let n = stream.read(&mut buffer).ok()?;
160 160 let request = String::from_utf8_lossy(&buffer[..n]);
161 161
@@ -247,15 +247,17 @@ impl OAuthCallbackServer {
247 247 }));
248 248 }
249 249
250 - // Extract code and state
250 + // Extract code and state, URL-decoding to handle encoded chars
251 251 let code = params.get("code")?;
252 252 let state = params.get("state")?;
253 + let code = urlencoding::decode(code).unwrap_or_else(|_| code.to_string());
254 + let state = urlencoding::decode(state).unwrap_or_else(|_| state.to_string());
253 255
254 256 // Store the success result
255 257 if let Ok(mut stored_guard) = stored.lock() {
256 258 *stored_guard = StoredCallback::Success {
257 - code: code.to_string(),
258 - state: state.to_string(),
259 + code: code.clone(),
260 + state: state.clone(),
259 261 };
260 262 }
261 263
@@ -276,8 +278,8 @@ impl OAuthCallbackServer {
276 278 );
277 279
278 280 Some(Ok(CallbackResult {
279 - code: code.to_string(),
280 - state: state.to_string(),
281 + code,
282 + state,
281 283 }))
282 284 }
283 285
@@ -298,10 +300,10 @@ impl OAuthCallbackServer {
298 300 }
299 301 }
300 302
301 - /// Simple URL decoding.
303 + /// Simple URL decoding with proper multi-byte UTF-8 support.
302 304 mod urlencoding {
303 305 pub fn decode(s: &str) -> Result<String, ()> {
304 - let mut result = String::with_capacity(s.len());
306 + let mut bytes = Vec::with_capacity(s.len());
305 307 let mut chars = s.chars();
306 308
307 309 while let Some(c) = chars.next() {
@@ -309,19 +311,21 @@ mod urlencoding {
309 311 let hex: String = chars.by_ref().take(2).collect();
310 312 if hex.len() == 2 {
311 313 if let Ok(byte) = u8::from_str_radix(&hex, 16) {
312 - result.push(byte as char);
314 + bytes.push(byte);
313 315 continue;
314 316 }
315 317 }
316 318 return Err(());
317 319 } else if c == '+' {
318 - result.push(' ');
320 + bytes.push(b' ');
319 321 } else {
320 - result.push(c);
322 + let mut buf = [0u8; 4];
323 + let encoded = c.encode_utf8(&mut buf);
324 + bytes.extend_from_slice(encoded.as_bytes());
321 325 }
322 326 }
323 327
324 - Ok(result)
328 + String::from_utf8(bytes).map_err(|_| ())
325 329 }
326 330 }
327 331