From f35c6fd90a5d66b7087ebf88b86072cc89c1bd3b Mon Sep 17 00:00:00 2001
From: Maurus Decimus <11444311+mdecimus@users.noreply.github.com>
Date: Fri, 17 Jul 2026 17:16:34 +0200
Subject: [PATCH] Report which email failed to import when the blob is too
large (fixes #22)
---
CHANGELOG.md | 1 +
src/db/blobs.rs | 20 ++++++++++++++
src/sync/export.rs | 4 +++
src/sync/export/email.rs | 60 ++++++++++++++++++++++++++++++++++------
4 files changed, 77 insertions(+), 8 deletions(-)
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;
}