Sending stops with overQuota after 500 messages because old EmailSubmissions are never purged

Issue Description

An account can’t send anymore once it has 500 EmailSubmission records. EmailSubmission/set then fails with overQuota until someone deletes records or raises the limit.

Expected Behavior

old submissions are purged after expungeSubmissionsAfter, and a client that never destroys its EmailSubmission records can keep sending.

Actual Behavior

the records stay forever and count toward x:Email.maxSubmissions (500 by default). Once an account reaches it, every new submission is refused:

"notCreated": {"s": {"type": "overQuota", "description": "There are too many email submissions, please delete some before adding a new one."}}

The Email/set in the same request still creates the draft. Cleaning up from the client side is hard too: EmailSubmission/query and EmailSubmission/get with ids: null only return records from the last 3 days, so the older ones can’t be found but still count.

Reproduction Steps

1 As admin, give a user a low limit: x:Account/set with “quotas/maxEmailSubmissions”: 2.
2 As that user, send three messages (Email/set + EmailSubmission/set).
3 The third EmailSubmission/set fails with overQuota. Destroying one submission, or removing the override, makes the next send work again.

Without the override the same happens at the 501st record. I didn’t run that, it’s the same code.

The purge (found in the code, didn’t run it):
1 Set x:DataRetention expungeSubmissionsAfter to 60000 and restart.
2 Send two messages and note the submission ids.
3 Wait a minute, then run the account purge: x:Task/set create {“@type”: “AccountMaintenance”, “accountId”: “”, “maintenanceType”: “purge”}.
4 EmailSubmission/get with both ids still returns them.

Stalwart Version

v0.16.x

Installation Method

Built from source

Database Backend

RocksDB

Blob Storage

RocksDB

Search Engine

Internal

Directory Backend

Internal

Additional Context

in crates/email/src/message/delete.rs, purge_email_submissions iterates from a key with Collection::EmailSubmission (line 236) to a key with Collection::Email (line 245). Both keys start with the account id and then the collection byte, and EmailSubmission (4) sorts after Email (0), so the range is empty and nothing is deleted. It came in with d5c5f12ad.

Even with the end key fixed, the purge only clears the metadata and index entry (lines 284-292). The archive stays and no change is logged, while destroy in jmap/src/submission/set.rs does both.

count_submissions (jmap/src/submission/set.rs, lines 78-111) counts every record regardless of age or status. For a limit it would probably make more sense to count only submissions that can still be undone.

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

Confirmed in the current source: purge_email_submissions in crates/email/src/message/delete.rs builds an IterateParams range whose start key uses Collection::EmailSubmission and whose end key uses Collection::Email. Since collections are ordered by their numeric id (Email = 0, EmailSubmission = 4), the end key sorts before the start key, so the range is empty and the iterator never yields any submission ids to delete. That matches your reproduction: no submissions get purged even after expungeSubmissionsAfter and an AccountMaintenance purge task.

I also checked the follow-on step you flagged: even if the key range were fixed, the delete loop only issues .clear(EmailSubmissionField::Metadata) and clears the IndexProperty index entry, it does not clear the archived record the way destroy does in jmap/src/submission/set.rs (which removes the index, metadata, and the archive, and logs the change). So a corrected range would still leave orphaned archive data and no change-log entry, unlike explicit destroys.

And count_submissions (same set.rs file) does an unconditional count of all EmailSubmission records for the quota check in EmailSubmission/set, with no filter for status or age, which is why records that are eligible for purge (or already undoable-window-expired) still count against maxEmailSubmissions.

This is a legitimate bug: expired email submissions are never actually purged due to the reversed key range, they leak indefinitely and eventually trip the overQuota limit, and even a fixed purge routine would need to mirror the full delete-plus-changelog behavior of EmailSubmission/destroy and the quota counter would need to only count “live” (undoable) submissions. This isn’t something to work around via docs; it needs a code fix, so I’ll leave this for a human/staff follow-up rather than speculate on a patch.

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

This has been fixed in the v1.0.0 branch.

Thanks

(i need at least 20 chars to reply)

@stalwart I have also send a mail with other issues to [email protected] btw