Vandelay migration error due to missing blob links after Stalwart upgrade

Issue Description

I am migrating a 4-node cluster of email servers from Stalwart v0.13.4 to v0.16.7 using the official migration guide (including the proxy and Vandelay). Because v0.13.4 does not expose the urn:ietf:params:jmap:principals JMAP capability that Vandelay requires for account name resolution, I first upgraded the source cluster from v0.13.4 to v0.15.5 (following the upgrade guide) to gain that capability before attempting the Vandelay export.

After upgrading to v0.15.5 and running Vandelay’s JMAP import against the source cluster, I received “email blob missing” warnings for the vast majority of emails in the first account I attempted (3,290 out of 4,097). The 807 emails that succeeded were all received on or after 2025-09-03. All emails received before that date failed, but are still visible in Roundcube Webmail and Mail.app on macOS.

After extensive investigation I determined the following:

  1. The S3 blob store contains 122,437 objects. The actual email content for the failing emails is present in the bucket under keys derived from their BLAKE3 hashes using Stalwart’s custom Base32 encoding.

  2. The MySQL data store k table has only 9,857 entries across all accounts. Encoding these entries using Stalwart’s Base32Writer confirms they all correspond to objects in the S3 bucket.

  3. The JMAP blob IDs for the failing emails decode (via BlobId::from_base32) to hashes that do NOT have corresponding BlobOp::Commit entries in the k table.

  4. Cross-referencing email metadata entries (EmailField::Metadata, field byte 71) in the p table against the k table and the S3 bucket across all accounts on the cluster reveals:

    • 360,361 total email metadata entries
    • 6,363 already have valid k table entries (OK)
    • 54,166 have blobs present in S3 but NO corresponding k table entry
    • 19,924 have no k table entry AND no blob in S3 (these appear to be unrecoverable)
  5. The emails missing k table entries are all older emails that predate when blob link tracking was introduced in the codebase. They have valid blob_hash values in their MessageMetadata rkyv archives and their content exists in S3, but the BlobOp::Commit and BlobOp::Link entries were never created for them, not by the original ingestion, and not by the v0.15.5 migration.

  6. The v0.15.5 migration’s migrate_emails_v014 correctly carries the blob_hash from LegacyMessageMetadata into the new MessageMetadata struct, but does not create the corresponding k table entries. The migrate_blobs_v014 function only re-migrates existing blob link entries from the old format and cannot create entries that never existed.

Expected Behavior

After upgrading from v0.13.4 to v0.15.5, all emails whose blob content is present in the configured blob store should be accessible via JMAP blob download, and Vandelay should be able to import them successfully into an archive file.

Actual Behavior

After upgrading to v0.15.5, JMAP blob downloads return HTTP 404 for all emails that lack k table entries, even though their content exists in S3. Specifically:

  • GET /jmap/download/{accountId}/{blobId}/{name} returns {"status":404,"title":"Not Found","detail":"The requested resource does not exist on this server."}
  • Vandelay reports warning: Email {id} skipped: malformed jmap response: email blob missing for each affected email
  • The JMAP Email/get method returns valid blobId values for these emails (the blob IDs are correctly constructed from the stored hashes), but downloading those blobs fails
  • Emails ARE visible and browsable in IMAP and Roundcube (which use a different code path), giving the false impression that the mailbox is intact

Reproduction Steps

  1. Run Stalwart from v0.11.x, accumulating emails over an extended period (months/years). Configure it with MySQL as the data store and Wasabi S3 as the blob store.
  2. Upgrade through v0.13.4, continuing to receive mail
  3. Install Vandelay and set up a v0.16.7 deployment with the migration proxy per the official migration guide
  4. Upgrade source cluster to v0.15.5 to gain the urn:ietf:params:jmap:principals support needed by Vandelay
  5. Run Vandelay import against the v0.15.5 source:
    vandelay import jmap \
      --url https://mail.example.com \
      --auth-basic "[email protected]" \
      --account-name "[email protected]" \
      user.sqlite
    
  6. Observe “email blob missing” warnings for all emails predating blob link tracking introduction
  7. Confirm via JMAP Email/get that affected emails have valid blobId values
  8. Confirm via direct S3 listing that blob objects exist in the bucket
  9. Confirm via the k table that no BlobOp::Commit entries exist for those blob hashes

Relevant Log Output

From Vandelay:

...

warning: Email mwyaaadl2 skipped: malformed jmap response: email blob missing
warning: Email mw2aaadl0 skipped: malformed jmap response: email blob missing
warning: Email mxeaaadl3 skipped: malformed jmap response: email blob missing
warning: Email mxiaaadma skipped: malformed jmap response: email blob missing
warning: Email mxmaaadmb skipped: malformed jmap response: email blob missing
warning: Email mzaaaadmu skipped: malformed jmap response: email blob missing
warning: Email m1eaaadn0 skipped: malformed jmap response: email blob missing
warning: Email m1iaaadn1 skipped: malformed jmap response: email blob missing
warning: Email m3eaaadof skipped: malformed jmap response: email blob missing
warning: Email m3iaaadog skipped: malformed jmap response: email blob missing
warning: Email m3maaadoh skipped: malformed jmap response: email blob missing
warning: Email m3qaaadoi skipped: malformed jmap response: email blob missing
warning: Email m3uaaadoj skipped: malformed jmap response: email blob missing
warning: Email m3yaaadok skipped: malformed jmap response: email blob missing
warning: Email m32aaadol skipped: malformed jmap response: email blob missing
warning: Email naaaaadom skipped: malformed jmap response: email blob missing
warning: Email naeaaadon skipped: malformed jmap response: email blob missing
warning: Email naiaaadoo skipped: malformed jmap response: email blob missing
warning: Email namaaadop skipped: malformed jmap response: email blob missing
import: Email done (fetched=807 deleted=0 skipped=0 failed=6580)
import: ContactCard ...
import: ContactCard done (fetched=0 deleted=0 skipped=0 failed=0)
import: CalendarEvent ...
import: CalendarEvent done (fetched=0 deleted=0 skipped=0 failed=0)
Mailbox: created=0 fetched=7 updated=0 deleted=0 skipped=0 failed=0
AddressBook: created=0 fetched=1 updated=0 deleted=0 skipped=0 failed=0
Calendar: created=0 fetched=1 updated=0 deleted=0 skipped=0 failed=0
FileNode: created=0 fetched=0 updated=0 deleted=0 skipped=0 failed=0
Identity: created=0 fetched=1 updated=0 deleted=0 skipped=0 failed=0
SieveScript: created=0 fetched=0 updated=0 deleted=0 skipped=0 failed=0
ParticipantIdentity: created=0 fetched=1 updated=0 deleted=0 skipped=0 failed=0
Email: created=0 fetched=807 updated=0 deleted=0 skipped=0 failed=6580
ContactCard: created=0 fetched=0 updated=0 deleted=0 skipped=0 failed=0
CalendarEvent: created=0 fetched=0 updated=0 deleted=0 skipped=0 failed=0
error: some objects failed; the archive is consistent and resumable

Stalwart Version

v0.15.x

Installation Method

Binary (Linux)

Database Backend

MySQL

Blob Storage

S3-compatible

Search Engine

MySQL

Directory Backend

Internal

Additional Context

From its inception, Nginx has been configured as a reverse proxy behind the 4-node Stalwart cluster to handle HTTP/HTTPS traffic. The Linode NodeBalancer (load balancing software) has also been configured to handle IMAPS/SMTP/Submission traffic, directing it to one of the four backend Stalwart servers.

To migrate to v0.16, a new deployment was installed via the binary method on a separate Linode that sits outside the cluster (but in the same datacenter). The migration proxy was installed on a second separate Linode that can communicate with both the new deployment and the source over the private network. Vandelay was installed on a MacBook with remote access to all servers.

I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.

on

I have searched this support forum (open and closed topics) and confirm this is not a duplicate.

on

I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.

on

The error you are seeing is because the blob no longer exists in the store, it is not a Vandelay issue in this case.
Regarding the missing blobs, can you confirm they exist on 0.11.x? We want to rule out a migration issue from 0.11.x to 0.15.x.
And finally, the recommended procedure is to migrate straight from 0.11.x to 0.16.x using Vandelay and the proxy. Do not perform all those intermediary upgrades as they increase risk. Since older Stalwart versions do not support urn:ietf:params:jmap:principals, you can reference account by id (or if you have their passwords, log into them directly using Vandelay).

Thank you for the quick response. A few clarifications:

  1. On blob existence: I can confirm the blobs do exist in the S3 store. As part of my investigation, I cross-referenced the blob hashes stored in EmailField::Metadata entries against the S3 bucket listing. For the failing emails, I encoded each hash using Stalwart’s internal Base32 alphabet and confirmed the objects are present in the bucket. For example, the first failing email (received 2023-12-15) has hash C2BB2F0D7E7007F367180362DCF12065E383954B9C157F0255839894A983AD6A which encodes to S3 key yk0s1dl1oad3gzyyanrnz2jamxryhfkltqkx1asvqomjjkmdvvva, and that object is confirmed present in the bucket. The issue is not missing blobs; it’s missing BlobOp::Commit entries in the k table for those hashes.
  2. On the v0.11.x question: We have been running v0.13.x for as long as I can recall. I do not have a v0.11.x database backup, so I cannot confirm the state from that era. I do have a v0.13.4 database backup taken two days ago, immediately before the v0.15.5 migration (and I only performed the migration to get Vandelay working as I didn’t know beforehand that I could use the account ID option in place of the account name). If there is anything useful to check in that backup to help diagnose the root cause, I am happy to do so.
  3. On using –account-id with Vandelay: I did try this approach, and the result was the same blob 404 errors. The account ID approach resolved the account resolution failure but the underlying blob access issue remained.
  4. On the recommended migration path: Noted for future reference, but for the time being, given that I am on v0.15.5 with 54,166 emails across the cluster whose blobs exist in S3 but lack k table entries, is there a supported repair path? A maintenance task, migration flag, or manual procedure that would create the missing BlobOp::Commit and BlobOp::Link entries for blobs confirmed present in the store would resolve the issue entirely.

Sorry, I meant to say that we need to rule out migration issues from v0.13.4 to v0.15.5. The recommended upgrade path is to upgrade straight from v0.13.4 to v0.16.x.
Can you try temporarily restoring your v0.13.4 backup to make sure the blobs are accessible? If they are, this will mean that something went wrong when upgrading from 0.13 to 0.15.

I restored the v0.13.4 database backup to a local Stalwart v0.13.4 instance and MySQL database server on my Mac, pointed at the same Wasabi S3 blob storage bucket. Here is what I found:

JMAP blob download (direct HTTP test):
Attempting to download a known failing blob via JMAP returns 404 on v0.13.4, the same as on v0.15.5:

curl -sk -u "[email protected]:***" \
  "http://localhost:8080/jmap/download/bm/cdblwlynpzyap29hdabwfxhrebs1ha2vjoobk3yckwbzrffjqowwulaaaa/email.eml"
{"type":"about:blank","status":404,"title":"Not Found","detail":"The requested resource does not exist on this server."}

This confirms the JMAP blob 404 errors predate the v0.13→v0.15 migration and are not caused by it.

Vandelay JMAP import via account ID:
Following your suggestion to use –account-id to work around the missing urn:ietf:params:jmap:principals capability, I tried this against the v0.13.4 instance:

vandelay import jmap \
  --url http://localhost:8080 \
  --auth-basic "[email protected]" \
  --account-id 44 \
  user-v013.sqlite

This failed entirely — every object type was aborted with trailing characters parse errors or Unknown capability errors:

warning: type Mailbox aborted: connection error: http status 400:
  {"detail":"trailing characters at line 1 column 59"}
warning: type Email aborted: connection error: http status 400:
  {"detail":"trailing characters at line 1 column 57"}
warning: type FileNode aborted: connection error: http status 400:
  {"detail":"Unknown capability: \"urn:ietf:params:jmap:filenode\""}

Protocol-specific importers (v0.13.4 local instance):
Using Vandelay’s individual protocol importers against the v0.13.4 instance, all data was retrieved successfully with zero failures:

vandelay import imap ...
email: created=4080 fetched=4080 updated=0 deleted=0 skipped=0 failed=0

vandelay import caldav ...
calendar: created=1 fetched=0 updated=0 deleted=0 skipped=0 failed=0
calendarevent: created=0 fetched=0 updated=0 deleted=0 skipped=0 failed=0

vandelay import carddav ...
addressbook: created=1 fetched=0 updated=0 deleted=0 skipped=0 failed=0
contactcard: created=0 fetched=0 updated=0 deleted=0 skipped=0 failed=0

Protocol-specific importers (live v0.15.5 cluster):
I also tested the same importers against the live v0.15.5 production cluster, which has 5 additional days of email that the v0.13.4 backup does not:

vandelay import imap ...
email: created=4099 fetched=4099 updated=0 deleted=0 skipped=0 failed=0

vandelay import caldav ...
calendar: created=1 fetched=0 updated=0 deleted=0 skipped=0 failed=0
calendarevent: created=0 fetched=0 updated=0 deleted=0 skipped=0 failed=0

vandelay import carddav ...
addressbook: created=1 fetched=0 updated=0 deleted=0 skipped=0 failed=0
contactcard: created=0 fetched=0 updated=0 deleted=0 skipped=0 failed=0

Zero failures across all protocols on both instances, and more importantly all email (even the many that a JMAP import against v0.15.5 thought were missing) was retrieved.

Summary:
The email content is fully intact and accessible via IMAP on both v0.13.4 and v0.15.5. The JMAP blob 404s appear to be caused by missing BlobOp::Commit entries in the k table for emails ingested under older Stalwart versions. These emails have valid blob_hash values in their MessageMetadata and their content exists in S3, but without k table entries Stalwart’s JMAP layer cannot authorize or serve the blob downloads. Importantly, these emails appear completely normal in IMAP and mail clients like Roundcube, giving users and administrators no indication that anything is wrong.

Using the protocol-specific importers (IMAP + CalDAV + CardDAV) is a workable path for completing our migration to v0.16. However, this is a workaround rather than a fix; the underlying issue means that JMAP blob access is silently broken for a large portion of our mailboxes on both v0.13.4 and v0.15.5, and would presumably remain broken on v0.16.7 as well unless the missing k table entries are created. Any other installations that have been running Stalwart since before blob link tracking was introduced will likely have the same problem.

Is there a supported repair path to create the missing k table entries for blobs that are confirmed present in the blob store? That would be the proper fix and would restore full JMAP functionality rather than working around it.