diff --git a/CHANGELOG.md b/CHANGELOG.md index c8157e3..65ae5e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to this project will be documented in this file. This projec ### Fixed - WebDAV import materialised the account root collection as a directory named after the account displayname (#18). - Report user friendly error message when `urn:ietf:params:jmap:principals` is not supported and no accountId is provided (#21). +- Report which email failed to import when the blob is too large (#22). ## [1.0.6] - 2026-07-12 diff --git a/src/db/blobs.rs b/src/db/blobs.rs index 1c91668..cc1e4bc 100644 --- a/src/db/blobs.rs +++ b/src/db/blobs.rs @@ -27,6 +27,18 @@ pub fn blob_bytes(conn: &Connection, id: i64) -> Result>, rusqlit .optional() } +pub fn blob_len(conn: &Connection, id: i64) -> Result, rusqlite::Error> { + conn.query_row( + "SELECT length(data) FROM blobs WHERE id = ?1", + params![id], + |row| { + let len: i64 = row.get(0)?; + Ok(len.max(0) as u64) + }, + ) + .optional() +} + pub fn gc_orphan_blobs(conn: &Connection) -> Result { conn.execute( "DELETE FROM blobs WHERE id NOT IN ( @@ -77,6 +89,14 @@ mod tests { assert_eq!(blob_bytes(&c, 9999).unwrap(), None); } + #[test] + fn blob_len_reports_byte_length() { + let c = mem(); + let id = intern_blob(&c, b"payload").unwrap(); + assert_eq!(blob_len(&c, id).unwrap(), Some(7)); + assert_eq!(blob_len(&c, 9999).unwrap(), None); + } + #[test] fn gc_keeps_referenced_and_reaps_orphans() { let c = mem(); diff --git a/src/sync/export.rs b/src/sync/export.rs index 0efa30b..b661c76 100644 --- a/src/sync/export.rs +++ b/src/sync/export.rs @@ -101,6 +101,10 @@ impl<'a> Uploader<'a> { self.cache.remove(&local_id); } + fn blob_len(&self, local_id: i64) -> Option { + db::blobs::blob_len(self.conn, local_id).ok().flatten() + } + fn take_touched(&mut self) -> Vec { std::mem::take(&mut self.touched) } diff --git a/src/sync/export/email.rs b/src/sync/export/email.rs index b8a7135..4d60556 100644 --- a/src/sync/export/email.rs +++ b/src/sync/export/email.rs @@ -143,6 +143,30 @@ fn build_keywords(row: &EmailRow) -> Map { kw } +fn blob_hint(uploader: &Uploader, row: &EmailRow) -> String { + let idx = index_from_json(&row.message_match); + let mut s = match idx.mids.first() { + Some(mid) => format!("message-id <{mid}>"), + None => "no message-id".to_owned(), + }; + if let Some(len) = uploader.blob_len(row.blob_local_id) { + use std::fmt::Write; + let _ = write!(s, ", {}", crate::inspect::format_bytes(len)); + } + s +} + +fn size_note(e: &JmapError) -> &'static str { + if matches!( + e, + JmapError::RequestTooLarge | JmapError::SingleObjectTooLarge(_) + ) { + "; exceeds the target server size limit, so this message is skipped and re-running will not migrate it" + } else { + "" + } +} + fn import_item( blob: String, mids: Map, @@ -171,7 +195,8 @@ fn export_one( Some(m) => m, None => { logger.warn(&format!( - "email local {local_id} skipped: mailbox not on target" + "Email/import {cid} ({}) skipped: mailbox not on target", + blob_hint(uploader, row) )); counts.failed += 1; return; @@ -180,7 +205,11 @@ fn export_one( let blob = match uploader.upload_with(row.blob_local_id, "message/rfc822") { Ok(b) => b.0, Err(e) => { - logger.warn(&format!("email blob upload failed: {e}")); + logger.warn(&format!( + "Email/import {cid} ({}) blob upload failed: {e}{}", + blob_hint(uploader, row), + size_note(&e) + )); counts.failed += 1; return; } @@ -197,11 +226,18 @@ fn export_one( retry_after_reupload(net, uploader, maps, &cid, row, counts, logger); } Ok(SingleImport::NotCreated { detail, .. }) => { - logger.warn(&format!("Email/import {cid} failed: {detail}")); + logger.warn(&format!( + "Email/import {cid} ({}) failed: {detail}", + blob_hint(uploader, row) + )); counts.failed += 1; } Err(e) => { - logger.warn(&format!("Email/import {cid} send failed: {e}")); + logger.warn(&format!( + "Email/import {cid} ({}) send failed: {e}{}", + blob_hint(uploader, row), + size_note(&e) + )); counts.failed += 1; } } @@ -220,7 +256,11 @@ fn retry_after_reupload( let blob = match uploader.upload_with(row.blob_local_id, "message/rfc822") { Ok(b) => b.0, Err(e) => { - logger.warn(&format!("Email/import {cid}: blob re-upload failed: {e}")); + logger.warn(&format!( + "Email/import {cid} ({}) blob re-upload failed: {e}{}", + blob_hint(uploader, row), + size_note(&e) + )); counts.failed += 1; return; } @@ -229,7 +269,8 @@ fn retry_after_reupload( Some(m) => m, None => { logger.warn(&format!( - "Email/import {cid} skipped: mailbox not on target" + "Email/import {cid} ({}) skipped: mailbox not on target", + blob_hint(uploader, row) )); counts.failed += 1; return; @@ -241,13 +282,16 @@ fn retry_after_reupload( Ok(SingleImport::Skipped) => counts.skipped += 1, Ok(SingleImport::NotCreated { detail, .. }) => { logger.warn(&format!( - "Email/import {cid} failed after blob re-upload: {detail}" + "Email/import {cid} ({}) failed after blob re-upload: {detail}", + blob_hint(uploader, row) )); counts.failed += 1; } Err(e) => { logger.warn(&format!( - "Email/import {cid} send failed after blob re-upload: {e}" + "Email/import {cid} ({}) send failed after blob re-upload: {e}{}", + blob_hint(uploader, row), + size_note(&e) )); counts.failed += 1; }