diff --git a/app/plugins/builtins/mail/plugin.py b/app/plugins/builtins/mail/plugin.py index 5f02b2a..0c631a8 100644 --- a/app/plugins/builtins/mail/plugin.py +++ b/app/plugins/builtins/mail/plugin.py @@ -2,13 +2,32 @@ from __future__ import annotations +import asyncio +import logging + from app.plugins.base import BasePlugin from app.plugins.manifest import PluginManifest, PluginRouteDef +logger = logging.getLogger(__name__) + + +async def _auto_sync_loop() -> None: + """Background loop: sync all active mail accounts every 5 minutes.""" + from app.plugins.builtins.mail.services import auto_sync_all_accounts + + while True: + try: + await auto_sync_all_accounts() + except Exception as exc: + logger.warning("auto_sync error: %s", exc) + await asyncio.sleep(300) + class MailPlugin(BasePlugin): """Mail plugin for email management: IMAP sync, SMTP send, threading, rules, PGP.""" + _auto_sync_task: asyncio.Task | None = None + manifest = PluginManifest( name="mail", version="1.0.0", @@ -29,3 +48,28 @@ class MailPlugin(BasePlugin): migrations=["0001_initial.sql"], permissions=[], ) + + async def on_activate( + self, db, service_container, event_bus + ) -> None: + """Activate plugin: register events + start auto-sync background task.""" + await super().on_activate(db, service_container, event_bus) + + if self._auto_sync_task is None or self._auto_sync_task.done(): + self._auto_sync_task = asyncio.create_task(_auto_sync_loop()) + logger.info("Mail plugin: auto-sync background task started") + + async def on_deactivate( + self, db, service_container, event_bus + ) -> None: + """Deactivate plugin: stop auto-sync task + unregister events.""" + if self._auto_sync_task is not None and not self._auto_sync_task.done(): + self._auto_sync_task.cancel() + try: + await self._auto_sync_task + except asyncio.CancelledError: + pass + self._auto_sync_task = None + logger.info("Mail plugin: auto-sync background task stopped") + + await super().on_deactivate(db, service_container, event_bus) diff --git a/app/plugins/builtins/mail/routes.py b/app/plugins/builtins/mail/routes.py index 2a011e9..37bd461 100644 --- a/app/plugins/builtins/mail/routes.py +++ b/app/plugins/builtins/mail/routes.py @@ -73,6 +73,8 @@ from app.plugins.builtins.mail.services import ( folder_to_response, forward_mail, get_account_password, + imap_create_folder, + imap_delete_folder, imap_delete_mail, imap_move_mail, imap_sync_account, @@ -514,6 +516,14 @@ async def create_folder( ) db.add(folder) await db.flush() + try: + await imap_create_folder(db, acc_id, data.imap_name, tenant_id) + except Exception: + import logging as _logging + + _logging.getLogger(__name__).warning( + "create_folder: IMAP CREATE failed (non-critical) for %s", data.imap_name + ) return folder_to_response(folder) @@ -559,6 +569,14 @@ async def delete_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_full_access(db, account, user_id) + try: + await imap_delete_folder(db, f_id, tenant_id) + except Exception: + import logging as _logging + + _logging.getLogger(__name__).warning( + "delete_folder: IMAP DELETE failed (non-critical) for folder %s", f_id + ) await db.delete(folder) diff --git a/app/plugins/builtins/mail/services.py b/app/plugins/builtins/mail/services.py index 70af4e7..51325a9 100644 --- a/app/plugins/builtins/mail/services.py +++ b/app/plugins/builtins/mail/services.py @@ -1866,3 +1866,168 @@ async def update_draft( pass return mail + + +# ─── IMAP Folder Create / Delete ─── + + +async def imap_create_folder( + db: AsyncSession, account_id: uuid.UUID, folder_name: str, tenant_id: uuid.UUID +) -> None: + """Create folder on IMAP server. + + Connects to IMAP, creates folder with CREATE command. + Non-critical: errors are logged, DB operation still succeeds. + """ + import logging + + logger = logging.getLogger(__name__) + + account = ( + await db.execute( + select(MailAccount).where( + and_(MailAccount.id == account_id, MailAccount.tenant_id == tenant_id) + ) + ) + ).scalar_one_or_none() + if not account: + logger.warning("imap_create_folder: account %s not found", account_id) + return + + 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) + + resp = await client.create(folder_name) + if resp.result != "OK": + logger.warning( + "imap_create_folder: CREATE failed for %s: %s", folder_name, resp + ) + else: + logger.info("imap_create_folder: created folder %s on IMAP", folder_name) + + except Exception as exc: + logger.warning("imap_create_folder: failed (non-critical): %s", exc) + finally: + if client is not None: + try: + await client.logout() + except Exception: + pass + + +async def imap_delete_folder( + db: AsyncSession, folder_id: uuid.UUID, tenant_id: uuid.UUID +) -> None: + """Delete folder from IMAP server. + + Connects to IMAP, deletes folder with DELETE command. + Non-critical: errors are logged, DB operation still succeeds. + """ + import logging + + logger = logging.getLogger(__name__) + + folder = ( + await db.execute( + select(MailFolder).where( + and_(MailFolder.id == folder_id, MailFolder.tenant_id == tenant_id) + ) + ) + ).scalar_one_or_none() + if not folder: + logger.warning("imap_delete_folder: folder %s not found", folder_id) + return + + account = ( + await db.execute( + select(MailAccount).where( + and_( + MailAccount.id == folder.account_id, + MailAccount.tenant_id == tenant_id, + ) + ) + ) + ).scalar_one_or_none() + if not account: + logger.warning("imap_delete_folder: account not found for folder %s", folder_id) + return + + 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) + + resp = await client.delete(folder.imap_name) + if resp.result != "OK": + logger.warning( + "imap_delete_folder: DELETE failed for %s: %s", folder.imap_name, resp + ) + else: + logger.info("imap_delete_folder: deleted folder %s on IMAP", folder.imap_name) + + except Exception as exc: + logger.warning("imap_delete_folder: failed (non-critical): %s", exc) + finally: + if client is not None: + try: + await client.logout() + except Exception: + pass + + +# ─── Auto-Sync ─── + + +async def auto_sync_all_accounts() -> None: + """Auto-sync all active mail accounts. + + Called periodically by the background scheduler. + Iterates all active mail accounts and syncs each one. + """ + import logging + + from app.core.db import get_session_factory + + logger = logging.getLogger(__name__) + + factory = get_session_factory() + async with factory() as db: + accounts = ( + await db.execute( + select(MailAccount).where(MailAccount.is_active.is_(True)) + ) + ).scalars().all() + + if not accounts: + return + + logger.info("auto_sync_all_accounts: syncing %d active account(s)", len(accounts)) + + for account in accounts: + try: + result = await imap_sync_account(db, account.id, account.tenant_id) + logger.info( + "auto_sync_all_accounts: synced account %s (%s): %s", + account.id, + account.username, + result, + ) + except Exception as exc: + logger.warning( + "auto_sync_all_accounts: failed for account %s: %s", + account.id, + exc, + ) + # commit per-account so partial progress is saved + try: + await db.commit() + except Exception: + await db.rollback() diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 3be11d5..2530f53 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -559,6 +559,10 @@ "sortFrom": "Absender", "sortSubject": "Betreff", "sortAsc": "Aufsteigend", - "sortDesc": "Absteigend" + "sortDesc": "Absteigend", + "syncSuccess": "Synchronisierung erfolgreich", + "syncFailed": "Synchronisierung fehlgeschlagen", + "syncing": "Synchronisiere...", + "autoSyncEnabled": "Auto-Sync aktiv" } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 8587c1f..ce2a18e 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -559,6 +559,10 @@ "sortFrom": "From", "sortSubject": "Subject", "sortAsc": "Ascending", - "sortDesc": "Descending" + "sortDesc": "Descending", + "syncSuccess": "Sync successful", + "syncFailed": "Sync failed", + "syncing": "Syncing...", + "autoSyncEnabled": "Auto-sync enabled" } } diff --git a/frontend/src/pages/Mail.tsx b/frontend/src/pages/Mail.tsx index 7099647..7278d46 100644 --- a/frontend/src/pages/Mail.tsx +++ b/frontend/src/pages/Mail.tsx @@ -36,6 +36,7 @@ import { moveMail, saveDraft, updateDraft, + triggerSync, type MailAccount, type MailFolder, type Mail, @@ -80,6 +81,7 @@ export function MailPage() { const [showMoveDropdown, setShowMoveDropdown] = useState(false); const [sortBy, setSortBy] = useState<'date' | 'from' | 'subject'>('date'); const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); + const [isSyncing, setIsSyncing] = useState(false); // Load accounts const loadAccounts = useCallback(async () => { @@ -585,14 +587,27 @@ export function MailPage() { { id: 'sync', plugin: 'mail', - label: 'Sync', + label: isSyncing ? t('mail.syncing') : 'Sync', group: 'tools', + disabled: isSyncing || !selectedAccountId, icon: ( - + ), - onClick: () => { /* sync trigger */ }, + onClick: async () => { + if (!selectedAccountId) return; + setIsSyncing(true); + try { + await triggerSync(selectedAccountId); + await loadMails(); + toast.success(t('mail.syncSuccess')); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } finally { + setIsSyncing(false); + } + }, }, ]; if (hasBulkSelection) {