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