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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user