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
+99 -11
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
# 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: deleted mail %s (UID %s) from IMAP", mail_id, uid_str)
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)