diff --git a/app/plugins/builtins/mail/routes.py b/app/plugins/builtins/mail/routes.py index 7756292..5925f04 100644 --- a/app/plugins/builtins/mail/routes.py +++ b/app/plugins/builtins/mail/routes.py @@ -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") diff --git a/app/plugins/builtins/mail/services.py b/app/plugins/builtins/mail/services.py index 3a9098c..3f5f737 100644 --- a/app/plugins/builtins/mail/services.py +++ b/app/plugins/builtins/mail/services.py @@ -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)