fix: IMAP sync overhaul - move to Trash, fix func import, fix count queries

- Fix missing func import (imap_sync_folder was crashing silently)
- imap_delete_mail now moves to Trash folder instead of permanent delete
- Add permanent parameter: permanent=True for Trash->delete, False for Inbox->Trash
- delete_mail route: move to Trash if not in Trash, permanent delete if in Trash
- empty_folder: move to Trash or permanent delete if already in Trash
- Add Papierkorb/Trash folder to create_mail_account
- Fix not Mail.is_seen -> Mail.is_seen.is_(False) (SQLAlchemy bug)
- Fix select(text("COUNT(*)")) -> select(func.count()).select_from(Mail)
- process_sync_queue reads permanent flag from payload
This commit is contained in:
Agent Zero
2026-07-20 13:17:07 +02:00
parent 5ded6f7d0b
commit b68accb9a1
2 changed files with 230 additions and 32 deletions
+131 -21
View File
@@ -597,9 +597,10 @@ async def empty_folder(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("mail:delete")),
):
"""Soft-delete all mails in a folder (sets deleted_at = now()).
"""Empty a folder: move all mails to Trash, or permanent delete if already in Trash.
The mails remain in the database but are hidden from the normal list.
If the folder IS the Trash folder, mails are permanently deleted (deleted_at + IMAP EXPUNGE).
If the folder is NOT Trash, mails are moved to Trash (folder_id changed + IMAP MOVE).
Returns the number of mails that were emptied.
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
@@ -614,7 +615,26 @@ async def empty_folder(
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
account = await _get_account(db, folder.account_id, tenant_id, user_id)
await _check_delegate_access(db, account, user_id, "delete")
# Soft-delete all non-deleted mails in this folder
# Check if this folder IS the Trash folder
is_trash = 'trash' in folder.imap_name.lower() or 'papierkorb' in folder.name.lower() or 'trash' in folder.name.lower()
# Find Trash folder for non-trash emptying
trash_folder = None
if not is_trash:
all_folders = (
await db.execute(
select(MailFolder).where(
and_(MailFolder.account_id == folder.account_id, MailFolder.tenant_id == tenant_id)
)
)
).scalars().all()
for f in all_folders:
if 'trash' in f.imap_name.lower() or 'papierkorb' in f.name.lower() or 'trash' in f.name.lower():
trash_folder = f
break
# Get all non-deleted mails in this folder
result = await db.execute(
select(Mail).where(
and_(
@@ -627,7 +647,44 @@ async def empty_folder(
mails = result.scalars().all()
now = datetime.now(UTC)
for mail in mails:
mail.deleted_at = now
if is_trash or not trash_folder:
# Permanent delete
mail.deleted_at = now
try:
await mail_services.imap_delete_mail(db, mail.id, tenant_id, permanent=True)
except Exception as exc:
import logging
logging.getLogger(__name__).warning(
"empty_folder: IMAP permanent delete failed for mail %s: %s, queuing for retry", mail.id, exc
)
queue_entry = MailSyncQueue(
tenant_id=tenant_id,
mail_id=mail.id,
operation="delete",
payload={"permanent": True},
status="pending",
last_error=str(exc),
)
db.add(queue_entry)
else:
# Move to Trash folder (keep visible in Trash)
mail.folder_id = trash_folder.id
try:
await mail_services.imap_delete_mail(db, mail.id, tenant_id, permanent=False)
except Exception as exc:
import logging
logging.getLogger(__name__).warning(
"empty_folder: IMAP move to Trash failed for mail %s: %s, queuing for retry", mail.id, exc
)
queue_entry = MailSyncQueue(
tenant_id=tenant_id,
mail_id=mail.id,
operation="delete",
payload={"permanent": False},
status="pending",
last_error=str(exc),
)
db.add(queue_entry)
await db.flush()
return {"emptied_count": len(mails)}
@@ -1555,25 +1612,78 @@ async def delete_mail(
raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"})
account = await _get_account(db, mail.account_id, tenant_id, user_id)
await _check_delegate_access(db, account, user_id, "delete")
# Soft-delete: set deleted_at = now()
mail.deleted_at = datetime.now(UTC)
await db.flush()
# IMAP delete: queue for retry if it fails
try:
await mail_services.imap_delete_mail(db, m_id, tenant_id)
except Exception as exc:
import logging
logging.getLogger(__name__).warning("IMAP delete failed for mail %s: %s, queuing for retry", m_id, exc)
queue_entry = MailSyncQueue(
tenant_id=tenant_id,
mail_id=m_id,
operation="delete",
payload={},
status="pending",
last_error=str(exc),
# Find the Trash folder for this account
trash_folder = None
all_folders = (
await db.execute(
select(MailFolder).where(
and_(MailFolder.account_id == mail.account_id, MailFolder.tenant_id == tenant_id)
)
)
db.add(queue_entry)
).scalars().all()
for f in all_folders:
if 'trash' in f.imap_name.lower() or 'trash' in f.name.lower() or 'papierkorb' in f.name.lower():
trash_folder = f
break
if trash_folder and mail.folder_id == trash_folder.id:
# Mail is already in Trash → permanent delete
mail.deleted_at = datetime.now(UTC)
await db.flush()
try:
await mail_services.imap_delete_mail(db, m_id, tenant_id, permanent=True)
except Exception as exc:
import logging
logging.getLogger(__name__).warning("IMAP permanent delete failed for mail %s: %s, queuing for retry", m_id, exc)
queue_entry = MailSyncQueue(
tenant_id=tenant_id,
mail_id=m_id,
operation="delete",
payload={"permanent": True},
status="pending",
last_error=str(exc),
)
db.add(queue_entry)
await db.flush()
elif trash_folder:
# Mail is NOT in Trash → move to Trash folder
mail.folder_id = trash_folder.id
await db.flush()
try:
await mail_services.imap_delete_mail(db, m_id, tenant_id, permanent=False)
except Exception as exc:
import logging
logging.getLogger(__name__).warning("IMAP move to Trash failed for mail %s: %s, queuing for retry", m_id, exc)
queue_entry = MailSyncQueue(
tenant_id=tenant_id,
mail_id=m_id,
operation="delete",
payload={"permanent": False},
status="pending",
last_error=str(exc),
)
db.add(queue_entry)
await db.flush()
else:
# No Trash folder found → permanent delete
mail.deleted_at = datetime.now(UTC)
await db.flush()
try:
await mail_services.imap_delete_mail(db, m_id, tenant_id, permanent=True)
except Exception as exc:
import logging
logging.getLogger(__name__).warning("IMAP delete failed for mail %s: %s, queuing for retry", m_id, exc)
queue_entry = MailSyncQueue(
tenant_id=tenant_id,
mail_id=m_id,
operation="delete",
payload={"permanent": True},
status="pending",
last_error=str(exc),
)
db.add(queue_entry)
await db.flush()
@router.post("/{mail_id}/move")