Skip to content

quota: Fix maildir quota drop to zero after IMAP MOVE#274

Open
LexxFedoroff wants to merge 1 commit intodovecot:mainfrom
LexxFedoroff:fix/maildir-quota-move-double-expunge
Open

quota: Fix maildir quota drop to zero after IMAP MOVE#274
LexxFedoroff wants to merge 1 commit intodovecot:mainfrom
LexxFedoroff:fix/maildir-quota-move-double-expunge

Conversation

@LexxFedoroff
Copy link

Problem

When mailbox_move() is called with the maildir quota driver, the source
mail's expunge is double-counted, causing reported quota to drop to 0 after
any IMAP MOVE operation.

Symptom

# Before move – correct
$ doveadm quota get -u user@example.com
Quota name  Type     Value  Limit   %
user        STORAGE   1387   5120  27

# After IMAP MOVE to another folder – broken
$ doveadm quota get -u user@example.com
Quota name  Type     Value  Limit   %
user        STORAGE      0   5120   0

The message physically exists in the destination folder; only the quota tracking
is wrong. The maildirsize file accumulates an unbalanced negative delta with no
corresponding positive entry.

Root Cause

Two separate code paths both account for the source mail's expunge:

1. Destination transactionquota_save_finishquota_check
quota_try_alloc:

quota_alloc_with_size(ctx, size=N, expunged_size=N);
//   ctx->bytes_used += N    (save to destination)
//   ctx->bytes_used -= N    (quota_used_apply_expunged subtracts expunged_size)
//   net: ctx->bytes_used = 0  → nothing written to maildirsize

2. Source transactionmail_expungequota_mail_expunge
quota_free_bytes:

// Always fires, regardless of ctx->moving
src_qt->bytes_used -= N;   // → writes "-N -1" to maildirsize

Result: maildirsize receives only the negative delta; the positive entry for
the destination is never written.

Fix

Add a moving flag to quota_transaction_context. It is set from
mail_save_context.moving in quota_check(). In quota_try_alloc(), pass
expunged_size=0 to quota_alloc_with_size() when ctx->moving is TRUE.

expunged_size remains unchanged in the quota_test_alloc() call, so moves at
the quota boundary are still correctly permitted. The actual accounting for the
source expunge continues to be handled by the source transaction's
quota_mail_expunge() -> quota_free_bytes() path.

After the fix:

Transaction bytes_used Written to maildirsize
Destination +N +N +1
Source -N -N -1
Net 0 correct

Testing

Reproduce with the maildir quota driver:

doveadm quota recalc -u user@example.com
doveadm move -u user@example.com INBOX.Drafts mailbox INBOX ALL
doveadm quota get -u user@example.com   # was 0 before this fix

COPY + EXPUNGE is unaffected (the moving flag remains FALSE in that path).

Files changed

  • src/plugins/quota/quota-private.h — add moving:1 bitfield to
    quota_transaction_context
  • src/plugins/quota/quota-storage.c — propagate ctx->moving into the quota
    transaction in quota_check()
  • src/plugins/quota/quota.c — skip subtracting expunged_size from
    bytes_used in quota_try_alloc() when the transaction is a MOVE

When mailbox_move() is called with the maildir quota driver, the source
mail expunge is double-counted, causing quota to drop to zero.

The destination transaction's quota_try_alloc() calls
quota_alloc_with_size(ctx, size, expunged_size), which subtracts
expunged_size via quota_used_apply_expunged(), netting bytes_used to 0.
At the same time, mail_expunge() on the source mail fires
quota_mail_expunge() -> quota_free_bytes() in the source transaction,
subtracting the full message size there as well.

Result: only the source transaction commits bytes_used=-size and writes
"-size -1" to maildirsize; the destination transaction commits
bytes_used=0 and writes nothing. Quota reports 0 after the move.

The fix adds a `moving` flag to quota_transaction_context that is set
from mail_save_context inside quota_check() when ctx->moving is TRUE.
quota_try_alloc() then passes expunged_size=0 to quota_alloc_with_size()
only for MOVE, leaving COPY/REPLACE behaviour unchanged.

expunged_size is still passed unmodified to quota_test_alloc(), so moves
at the quota boundary continue to be correctly permitted.
@LexxFedoroff LexxFedoroff marked this pull request as ready for review March 23, 2026 13:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants