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
+117 -7
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:
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,12 +1612,65 @@ 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()
# 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)
)
)
).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()
# IMAP delete: queue for retry if it fails
try:
await mail_services.imap_delete_mail(db, m_id, tenant_id)
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)
@@ -1568,7 +1678,7 @@ async def delete_mail(
tenant_id=tenant_id,
mail_id=m_id,
operation="delete",
payload={},
payload={"permanent": True},
status="pending",
last_error=str(exc),
)
+98 -10
View File
@@ -22,7 +22,7 @@ import pgpy
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from sqlalchemy import and_, or_, select, text
from sqlalchemy import and_, func, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -293,6 +293,7 @@ async def create_mail_account(
for fname, imap_name in [
("Gesendet", "INBOX.Sent"),
("Entwürfe", "INBOX.Drafts"),
("Papierkorb", "INBOX.Trash"),
("Spam", "INBOX.spam"),
]:
folder = MailFolder(
@@ -817,7 +818,7 @@ async def imap_sync_folder(
Mail.folder_id == folder_id,
Mail.tenant_id == tenant_id,
Mail.deleted_at.is_(None),
not Mail.is_seen,
Mail.is_seen.is_(False),
)
)
)
@@ -1171,7 +1172,7 @@ async def imap_sync_account(
if attachments and existing_mail.has_attachments:
existing_att_count = (
await db.execute(
select(text("COUNT(*)")).where(
select(func.count()).select_from(MailAttachment).where(
and_(
MailAttachment.mail_id == existing_mail.id,
MailAttachment.tenant_id == tenant_id,
@@ -1260,18 +1261,18 @@ async def imap_sync_account(
# Update folder counts
total = (
await db.execute(
select(text("COUNT(*)")).where(
select(func.count()).select_from(Mail).where(
and_(Mail.folder_id == folder.id, Mail.tenant_id == tenant_id)
)
)
).scalar()
unread = (
await db.execute(
select(text("COUNT(*)")).where(
select(func.count()).select_from(Mail).where(
and_(
Mail.folder_id == folder.id,
Mail.tenant_id == tenant_id,
not Mail.is_seen,
Mail.is_seen.is_(False),
)
)
)
@@ -2081,12 +2082,63 @@ async def imap_sync_mail_flags(
# ─── IMAP Delete ───
async def _find_trash_folder_name(
db: AsyncSession, account: MailAccount, tenant_id: uuid.UUID
) -> str | None:
"""Find the IMAP Trash folder name for an account.
Checks account.trash_folder_imap_name, then DB folders with 'trash' in imap_name,
then queries the IMAP server LIST for common Trash folder names.
"""
# 1) Explicit mapping on account
if account.trash_folder_imap_name:
return account.trash_folder_imap_name
# 2) DB folder with 'trash' in imap_name
db_folders = (
await db.execute(
select(MailFolder).where(
and_(MailFolder.account_id == account.id, MailFolder.tenant_id == tenant_id)
)
)
).scalars().all()
for f in db_folders:
if 'trash' in f.imap_name.lower():
return f.imap_name
# 3) Query IMAP server for common Trash folder names
password = await get_account_password(account)
client = None
try:
client = aioimaplib.IMAP4_SSL(host=account.imap_host, port=account.imap_port)
await client.wait_hello_from_server()
await client.login(account.username, password)
list_resp = await client.list('""', '"*"')
imap_folders, _ = _parse_imap_list_response(list_resp)
trash_candidates = ['Trash', 'INBOX.Trash', 'INBOX.Trash', 'Deleted', 'Deleted Items', 'Papierkorb']
for _, name in imap_folders:
if name in trash_candidates or 'trash' in name.lower():
return name
except Exception:
pass
finally:
if client:
try:
await client.logout()
except Exception:
pass
return None
async def imap_delete_mail(
db: AsyncSession, mail_id: uuid.UUID, tenant_id: uuid.UUID
db: AsyncSession, mail_id: uuid.UUID, tenant_id: uuid.UUID, *, permanent: bool = False
) -> None:
"""Delete mail from IMAP server.
Uses stored imap_uid directly; falls back to Message-ID search if missing.
If permanent=False (default): MOVE mail to IMAP Trash folder (like real mail clients).
If permanent=True: STORE \\Deleted + EXPUNGE (permanent delete from server).
Uses UID MOVE if supported, otherwise COPY + STORE \\Deleted + EXPUNGE.
Raises exceptions on failure so caller can queue for retry.
"""
import logging
@@ -2130,6 +2182,13 @@ async def imap_delete_mail(
logger.warning("imap_delete_mail: mail %s has no imap_uid or message_id, cannot sync", mail_id)
return
# Find Trash folder on IMAP server (only needed for non-permanent delete)
trash_folder_name = None
if not permanent:
trash_folder_name = await _find_trash_folder_name(db, account, tenant_id)
if not trash_folder_name:
logger.warning("imap_delete_mail: no Trash folder found for account %s, will permanent delete", account.id)
password = await get_account_password(account)
client = None
@@ -2156,10 +2215,38 @@ async def imap_delete_mail(
raise RuntimeError(f"no UID found for Message-ID {mail.message_id}")
uid_str = uids[0].decode() if isinstance(uids[0], bytes) else str(uids[0])
if permanent or not trash_folder_name:
# Permanent delete: STORE \Deleted + EXPUNGE
await client.uid('store', uid_str, r'+FLAGS (\\Deleted)')
await client.expunge()
logger.info("imap_delete_mail: permanent deleted mail %s (UID %s)", mail_id, uid_str)
return
logger.info("imap_delete_mail: deleted mail %s (UID %s) from IMAP", mail_id, uid_str)
# Non-permanent: MOVE to Trash folder (like Thunderbird, Outlook, etc.)
# Don't move if already in Trash
if folder.imap_name.lower() == trash_folder_name.lower():
# Already in Trash — permanent delete
await client.uid('store', uid_str, r'+FLAGS (\\Deleted)')
await client.expunge()
logger.info("imap_delete_mail: permanent deleted mail %s (already in Trash, UID %s)", mail_id, uid_str)
return
# Try UID MOVE first
try:
move_resp = await client.uid('move', uid_str, trash_folder_name)
if move_resp.result == 'OK':
logger.info("imap_delete_mail: moved mail %s (UID %s) to Trash %s", mail_id, uid_str, trash_folder_name)
return
except Exception as move_exc:
logger.info("imap_delete_mail: UID MOVE not supported, falling back: %s", move_exc)
# Fallback: COPY to Trash + STORE \Deleted + EXPUNGE
copy_resp = await client.uid('copy', uid_str, trash_folder_name)
if copy_resp.result != 'OK':
raise RuntimeError(f"COPY to Trash failed for mail {mail_id}")
await client.uid('store', uid_str, r'+FLAGS (\\Deleted)')
await client.expunge()
logger.info("imap_delete_mail: moved mail %s (UID %s) to Trash via COPY+DELETE", mail_id, uid_str)
except Exception as exc:
logger.warning("imap_delete_mail: failed for mail %s: %s", mail_id, exc)
@@ -2779,7 +2866,8 @@ async def process_sync_queue(db: AsyncSession) -> None:
for entry in pending:
try:
if entry.operation == "delete":
await imap_delete_mail(db, entry.mail_id, entry.tenant_id)
is_permanent = entry.payload.get("permanent", False) if entry.payload else False
await imap_delete_mail(db, entry.mail_id, entry.tenant_id, permanent=is_permanent)
elif entry.operation == "move":
target_folder_id = uuid.UUID(entry.payload.get("target_folder_id", ""))
await imap_move_mail(db, entry.mail_id, target_folder_id, entry.tenant_id)