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:
@@ -2,13 +2,32 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
from app.plugins.base import BasePlugin
|
from app.plugins.base import BasePlugin
|
||||||
from app.plugins.manifest import PluginManifest, PluginRouteDef
|
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):
|
class MailPlugin(BasePlugin):
|
||||||
"""Mail plugin for email management: IMAP sync, SMTP send, threading, rules, PGP."""
|
"""Mail plugin for email management: IMAP sync, SMTP send, threading, rules, PGP."""
|
||||||
|
|
||||||
|
_auto_sync_task: asyncio.Task | None = None
|
||||||
|
|
||||||
manifest = PluginManifest(
|
manifest = PluginManifest(
|
||||||
name="mail",
|
name="mail",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
@@ -29,3 +48,28 @@ class MailPlugin(BasePlugin):
|
|||||||
migrations=["0001_initial.sql"],
|
migrations=["0001_initial.sql"],
|
||||||
permissions=[],
|
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)
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ from app.plugins.builtins.mail.services import (
|
|||||||
folder_to_response,
|
folder_to_response,
|
||||||
forward_mail,
|
forward_mail,
|
||||||
get_account_password,
|
get_account_password,
|
||||||
|
imap_create_folder,
|
||||||
|
imap_delete_folder,
|
||||||
imap_delete_mail,
|
imap_delete_mail,
|
||||||
imap_move_mail,
|
imap_move_mail,
|
||||||
imap_sync_account,
|
imap_sync_account,
|
||||||
@@ -514,6 +516,14 @@ async def create_folder(
|
|||||||
)
|
)
|
||||||
db.add(folder)
|
db.add(folder)
|
||||||
await db.flush()
|
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)
|
return folder_to_response(folder)
|
||||||
|
|
||||||
|
|
||||||
@@ -559,6 +569,14 @@ async def delete_folder(
|
|||||||
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
|
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
|
||||||
account = await _get_account(db, folder.account_id, tenant_id, user_id)
|
account = await _get_account(db, folder.account_id, tenant_id, user_id)
|
||||||
await _check_delegate_full_access(db, account, 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)
|
await db.delete(folder)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1866,3 +1866,168 @@ async def update_draft(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
return mail
|
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()
|
||||||
|
|||||||
@@ -559,6 +559,10 @@
|
|||||||
"sortFrom": "Absender",
|
"sortFrom": "Absender",
|
||||||
"sortSubject": "Betreff",
|
"sortSubject": "Betreff",
|
||||||
"sortAsc": "Aufsteigend",
|
"sortAsc": "Aufsteigend",
|
||||||
"sortDesc": "Absteigend"
|
"sortDesc": "Absteigend",
|
||||||
|
"syncSuccess": "Synchronisierung erfolgreich",
|
||||||
|
"syncFailed": "Synchronisierung fehlgeschlagen",
|
||||||
|
"syncing": "Synchronisiere...",
|
||||||
|
"autoSyncEnabled": "Auto-Sync aktiv"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -559,6 +559,10 @@
|
|||||||
"sortFrom": "From",
|
"sortFrom": "From",
|
||||||
"sortSubject": "Subject",
|
"sortSubject": "Subject",
|
||||||
"sortAsc": "Ascending",
|
"sortAsc": "Ascending",
|
||||||
"sortDesc": "Descending"
|
"sortDesc": "Descending",
|
||||||
|
"syncSuccess": "Sync successful",
|
||||||
|
"syncFailed": "Sync failed",
|
||||||
|
"syncing": "Syncing...",
|
||||||
|
"autoSyncEnabled": "Auto-sync enabled"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
moveMail,
|
moveMail,
|
||||||
saveDraft,
|
saveDraft,
|
||||||
updateDraft,
|
updateDraft,
|
||||||
|
triggerSync,
|
||||||
type MailAccount,
|
type MailAccount,
|
||||||
type MailFolder,
|
type MailFolder,
|
||||||
type Mail,
|
type Mail,
|
||||||
@@ -80,6 +81,7 @@ export function MailPage() {
|
|||||||
const [showMoveDropdown, setShowMoveDropdown] = useState(false);
|
const [showMoveDropdown, setShowMoveDropdown] = useState(false);
|
||||||
const [sortBy, setSortBy] = useState<'date' | 'from' | 'subject'>('date');
|
const [sortBy, setSortBy] = useState<'date' | 'from' | 'subject'>('date');
|
||||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||||
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
|
|
||||||
// Load accounts
|
// Load accounts
|
||||||
const loadAccounts = useCallback(async () => {
|
const loadAccounts = useCallback(async () => {
|
||||||
@@ -585,14 +587,27 @@ export function MailPage() {
|
|||||||
{
|
{
|
||||||
id: 'sync',
|
id: 'sync',
|
||||||
plugin: 'mail',
|
plugin: 'mail',
|
||||||
label: 'Sync',
|
label: isSyncing ? t('mail.syncing') : 'Sync',
|
||||||
group: 'tools',
|
group: 'tools',
|
||||||
|
disabled: isSyncing || !selectedAccountId,
|
||||||
icon: (
|
icon: (
|
||||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className={`w-3.5 h-3.5 ${isSyncing ? 'animate-spin' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
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) {
|
if (hasBulkSelection) {
|
||||||
|
|||||||
Reference in New Issue
Block a user