feat: mail phase 4 - IMAP folder create/delete + auto-sync

- Add imap_create_folder() and imap_delete_folder() service functions
- Call IMAP CREATE/DELETE in folder create/delete endpoints
- Add auto_sync_all_accounts() background task (every 5 min)
- Start auto-sync loop on plugin activation via asyncio.create_task
- Frontend: working sync button with isSyncing state and toast feedback
- i18n: syncSuccess, syncFailed, syncing, autoSyncEnabled keys (de/en)
This commit is contained in:
Agent Zero
2026-07-15 20:28:11 +02:00
parent 4e100e9d33
commit f1a12092a0
6 changed files with 255 additions and 5 deletions
+44
View File
@@ -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)
+18
View File
@@ -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)
+165
View File
@@ -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()