Spam classifier: removing a same-day duplicate training sample clears the blob link of the sample that is kept

Issue Description

Each SpamTrainingSample keeps its message through a temporary blob link keyed by blob hash, account and until, without the sample id. until is the current day’s midnight plus holdSamplesFor, so two samples of the same message created on the same day, for example when a message is moved to Junk and back, share one link.

When training removes the duplicate sample, it clears that link. It is also the link of the sample that is kept, so once the message is deleted and its blob purged, the kept sample has no content. Deleting a sample explicitly through the API clears the same shared link.

Code (v0.16.24):

  • crates/store/src/write/key.rs L293-296: the key of BlobLink::Temporary is hash, account_id and until.
  • crates/email/src/message/ingest.rs L931 and L948-951: until = midnight + hold_samples_for, and the sample’s link is BlobLink::Temporary { until } on the message blob.
  • crates/spam-filter/src/modules/classifier.rs L250-256 and L884-891: a duplicate sample is removed with its BlobLink::Temporary { until } cleared.
  • crates/jmap/src/registry/mapping/spam_sample.rs L202-207: explicit deletion clears the same link.

Expected Behavior

Removing one sample leaves the blob of any other sample of the same message available for its retention period.

Actual Behavior

After the duplicate is removed, the remaining sample has no blob link. When the message is deleted and its blob purged, the remaining sample’s content returns 404.

Reproduction Steps

  1. Deliver a message to a mailbox with the spam classifier enabled.
  2. On the same day, move it to Junk and back to Inbox: two SpamTrainingSample objects are created with the same expiresAt.
  3. Run the spam filter training task: one sample remains.
  4. Before that sample’s expiresAt, delete the message, with no other copy referencing its blob, and run the blob purge: the sample still exists, and downloading its blob returns 404.
  5. Control: with a single move, the sample’s blob survives the message’s deletion.

Stalwart Version

v0.16.x

Installation Method

Binary (Linux)

Database Backend

RocksDB

Blob Storage

RocksDB

Search Engine

Internal

Directory Backend

Internal

Additional Context

One option could be a temporary link specific to each sample, which would keep expiry and automatic cleanup unchanged.

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

This is a well-researched bug report with specific code references. It’s not something I can resolve or confirm by searching docs, so it needs a human/maintainer to review the code paths you’ve cited and confirm the fix approach.

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.

Fixed in v0.16.25 (will be released today).