2026-07-01 15:41:27 +02:00
""" Mail plugin — IMAP/SMTP, threading, templates, rules, PGP, delegates. """
from __future__ import annotations
2026-07-15 20:28:11 +02:00
import asyncio
import logging
2026-08-16 01:17:18 +02:00
from datetime import UTC
2026-07-15 21:00:32 +02:00
from typing import Any
2026-07-15 20:28:11 +02:00
2026-07-01 15:41:27 +02:00
from app . plugins . base import BasePlugin
2026-08-16 01:17:18 +02:00
from app . plugins . manifest import (
FrontendDetailTab ,
FrontendMenuItem ,
FrontendPageRoute ,
FrontendSettingsPage ,
PluginManifest ,
PluginRouteDef ,
)
2026-07-01 15:41:27 +02:00
2026-07-15 20:28:11 +02:00
logger = logging . getLogger ( __name__ )
2026-08-16 01:17:18 +02:00
async def _mail_restore_handler (
db , entity , action : str , snapshot : dict , context : dict ,
) - > dict :
""" Special restore handler for Mail entities (moved from core, P0-7 fix).
Mail restore has IMAP semantics:
- delete: move back from trash to original folder (if folder still exists)
- update: revert metadata fields
- create: soft-delete (undo send only works for drafts)
"""
import uuid
from datetime import datetime
from sqlalchemy import select
user_id = context . get ( " user_id " )
tenant_id = context . get ( " tenant_id " )
if action == " delete " :
if entity is None :
raise ValueError ( " Mail entity not found for restore " )
entity . deleted_at = None
if user_id :
entity . updated_by = user_id if hasattr ( entity , " updated_by " ) else None
original_folder_id = snapshot . get ( " folder_id " )
if original_folder_id and hasattr ( entity , " folder_id " ) :
try :
folder_uuid = uuid . UUID ( str ( original_folder_id ) )
from app . plugins . builtins . mail . models import MailFolder
folder_q = select ( MailFolder ) . where (
MailFolder . id == folder_uuid ,
MailFolder . tenant_id == tenant_id ,
MailFolder . deleted_at . is_ ( None ) ,
)
folder_result = await db . execute ( folder_q )
folder = folder_result . scalar_one_or_none ( )
if folder :
entity . folder_id = folder_uuid
else :
logger . warning (
" Original mail folder %s no longer exists, "
" restoring mail without folder assignment " ,
original_folder_id ,
)
except ( ValueError , Exception ) as e :
logger . warning ( " Failed to restore mail folder: %s " , e )
await db . flush ( )
return { " id " : str ( entity . id ) , " restored " : True , " entity_type " : " mail " }
elif action == " update " :
if entity is None :
raise ValueError ( " Mail entity not found for restore " )
from app . core . restore_registry import _DEFAULT_EXCLUDED
excluded = _DEFAULT_EXCLUDED | {
" message_id " , " rfc822_size " , " raw_path " , " account_id " , " folder_id " ,
}
for key , value in snapshot . items ( ) :
if hasattr ( entity , key ) and key not in excluded :
setattr ( entity , key , value )
await db . flush ( )
return { " id " : str ( entity . id ) , " restored " : True , " entity_type " : " mail " }
elif action == " create " :
if entity is None :
raise ValueError ( " Mail entity not found for restore " )
entity . deleted_at = datetime . now ( UTC )
await db . flush ( )
return { " id " : str ( entity . id ) , " restored " : True , " entity_type " : " mail " , " note " : " soft-deleted (undo create) " }
raise ValueError ( f " Unsupported action for mail restore: { action } " )
2026-07-15 20:28:11 +02:00
async def _auto_sync_loop ( ) - > None :
2026-07-20 11:31:17 +02:00
""" Background loop: process pending sync queue, then sync all active mail accounts every 5 minutes. """
from app . core . db import get_session_factory
2026-08-16 01:17:18 +02:00
from app . plugins . builtins . mail . services import auto_sync_all_accounts , process_sync_queue
2026-07-15 20:28:11 +02:00
while True :
2026-07-20 11:31:17 +02:00
try :
factory = get_session_factory ( )
async with factory ( ) as db :
await process_sync_queue ( db )
await db . commit ( )
except Exception as exc :
logger . warning ( " process_sync_queue error: %s " , exc )
2026-07-15 20:28:11 +02:00
try :
await auto_sync_all_accounts ( )
except Exception as exc :
logger . warning ( " auto_sync error: %s " , exc )
2026-07-20 12:01:16 +02:00
await asyncio . sleep ( 60 )
2026-07-15 20:28:11 +02:00
2026-07-01 15:41:27 +02:00
class MailPlugin ( BasePlugin ) :
""" Mail plugin for email management: IMAP sync, SMTP send, threading, rules, PGP. """
2026-08-23 19:24:12 +02:00
def __init__ ( self ) - > None :
super ( ) . __init__ ( )
# Instance attribute: multiple plugin instances must not share the
# background task state (ARCH-036).
self . _auto_sync_task : asyncio . Task | None = None
2026-07-15 20:28:11 +02:00
2026-07-01 15:41:27 +02:00
manifest = PluginManifest (
name = " mail " ,
2026-07-20 13:42:30 +02:00
version = " 1.3.0 " ,
2026-07-01 15:41:27 +02:00
display_name = " Mail " ,
description = (
" Email management: IMAP sync, SMTP send, threading, "
" templates, rules, vacation, PGP, delegates, labels. "
) ,
dependencies = [ ] ,
routes = [
PluginRouteDef (
path = " /api/v1/mail " ,
module = " app.plugins.builtins.mail.routes " ,
router_attr = " router " ,
) ,
] ,
events = [ ] ,
2026-07-24 10:37:44 +02:00
migrations = [ " 0001_initial.sql " , " 0006_flag_type.sql " , " 0007_sync_queue.sql " , " 0008_sync_queue_deleted_at.sql " , " 0009_remove_mail_soft_delete.sql " , " 0010_add_deleted_at.sql " ] ,
2026-07-16 00:34:36 +02:00
permissions = [ " mail:read " , " mail:send " , " mail:config " , " mail:share " , " mail:write " , " mail:delete " ] ,
2026-07-23 19:01:18 +02:00
menu_items = [
2026-07-24 22:04:45 +02:00
FrontendMenuItem ( label_key = ' nav.mail ' , label = ' E-Mail ' , path = ' /mail ' , icon = ' Mail ' , order = 30 ) ,
2026-07-23 19:01:18 +02:00
] ,
page_routes = [
FrontendPageRoute ( path = ' /mail ' , component = ' @/pages/Mail ' , protected = True ) ,
FrontendPageRoute ( path = ' /mail/settings ' , component = ' @/pages/MailSettings ' , protected = True ) ,
] ,
settings_pages = [
FrontendSettingsPage ( path = ' mail ' , label_key = ' settings.mail ' , label = ' Mail ' , component = ' @/pages/MailSettings ' , icon = ' Mail ' , order = 50 ) ,
] ,
detail_tabs = [
FrontendDetailTab ( entity_type = ' contact ' , label_key = ' tabs.email ' , label = ' E-Mails ' , component = ' @/components/contact/ContactMailTab ' , icon = ' Mail ' , order = 20 , permission = ' mail:read ' ) ,
] ,
2026-07-26 23:15:34 +02:00
author = " LeoCRM Team " ,
min_app_version = " 1.0.0 " ,
hooks = [ " mail.before_send " , " mail.after_send " ] ,
contract_version = " 1.0.0 " ,
2026-07-01 15:41:27 +02:00
)
2026-07-15 20:28:11 +02:00
async def on_activate (
self , db , service_container , event_bus
) - > None :
2026-08-16 01:17:18 +02:00
""" Activate plugin: register events, restore, history + start auto-sync. """
2026-07-15 20:28:11 +02:00
await super ( ) . on_activate ( db , service_container , event_bus )
2026-08-16 01:17:18 +02:00
# Register restore config for Mail entities (P0-7 fix)
from app . core . restore_registry import RestoreConfig , get_restore_registry
from app . plugins . builtins . mail . models import Mail
get_restore_registry ( ) . register ( RestoreConfig (
entity_type = " mail " ,
model_class = Mail ,
restore_permission = " mail:write " ,
excluded_fields = frozenset ( { " message_id " , " rfc822_size " , " raw_path " , " account_id " , " folder_id " } ) ,
special_handler = _mail_restore_handler ,
) )
# Register history hooks for Mail entities (P0-8 fix)
from app . core . history_hooks import register_history_hooks
from app . core . hooks import get_hook_registry
register_history_hooks (
get_hook_registry ( ) , " mail " ,
" mail.after_create " , " mail.after_update " , " mail.after_delete " ,
owner_tag = " mail " ,
)
2026-07-15 20:28:11 +02:00
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 " )
2026-07-15 21:00:32 +02:00
def get_notification_types ( self ) - > list [ dict [ str , Any ] ] :
""" Return the notification types this mail plugin registers. """
return [
{ " type_key " : " mail_new " , " category " : " mail " , " label " : " Neue E-Mail empfangen " , " description " : " Benachrichtigung bei neuen E-Mails " , " is_enabled_by_default " : True } ,
{ " type_key " : " mail_error " , " category " : " mail " , " label " : " IMAP-Verbindungsfehler " , " description " : " Fehler bei der Verbindung zum Mailserver " , " is_enabled_by_default " : True } ,
{ " type_key " : " mail_auth " , " category " : " mail " , " label " : " IMAP-Login-Fehler " , " description " : " Anmeldung am Mailserver fehlgeschlagen " , " is_enabled_by_default " : True } ,
{ " type_key " : " mail_quota " , " category " : " mail " , " label " : " Postfach fast voll " , " description " : " Warnung bei hohem Postfach-Füllstand " , " is_enabled_by_default " : True } ,
{ " type_key " : " mail_sync_error " , " category " : " mail " , " label " : " Sync-Fehler " , " description " : " Synchronisierung fehlgeschlagen " , " is_enabled_by_default " : True } ,
{ " type_key " : " mail_sent " , " category " : " mail " , " label " : " E-Mail gesendet " , " description " : " Bestätigung beim Senden einer E-Mail " , " is_enabled_by_default " : False } ,
{ " type_key " : " mail_send_error " , " category " : " mail " , " label " : " SMTP-Sendefehler " , " description " : " E-Mail konnte nicht gesendet werden " , " is_enabled_by_default " : True } ,
{ " type_key " : " mail_draft " , " category " : " mail " , " label " : " Entwurf gespeichert " , " description " : " Bestätigung beim Speichern eines Entwurfs " , " is_enabled_by_default " : False } ,
{ " type_key " : " mail_account " , " category " : " mail " , " label " : " Account deaktiviert " , " description " : " Warnung bei deaktiviertem Mail-Account " , " is_enabled_by_default " : True } ,
{ " type_key " : " mail_folder " , " category " : " mail " , " label " : " Ordner erstellt/gelöscht " , " description " : " Bestätigung bei Ordner-Operationen " , " is_enabled_by_default " : False } ,
]
2026-07-15 20:28:11 +02:00
async def on_deactivate (
self , db , service_container , event_bus
) - > None :
""" Deactivate plugin: stop auto-sync task + unregister events. """
2026-07-26 23:15:34 +02:00
# Contract abmelden
from app . plugins . builtins . contracts import get_contract_registry
get_contract_registry ( ) . unregister ( self . manifest . name )
2026-07-15 20:28:11 +02:00
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 " )
2026-08-16 01:17:18 +02:00
# Unregister history hooks (free functions, not bound methods)
from app . core . hooks import get_hook_registry
get_hook_registry ( ) . unregister_actions_by_owner ( " mail.after_create " , " mail " )
get_hook_registry ( ) . unregister_actions_by_owner ( " mail.after_update " , " mail " )
get_hook_registry ( ) . unregister_actions_by_owner ( " mail.after_delete " , " mail " )
# Unregister restore config for Mail entities
from app . core . restore_registry import get_restore_registry
get_restore_registry ( ) . unregister ( " mail " )
2026-07-15 20:28:11 +02:00
await super ( ) . on_deactivate ( db , service_container , event_bus )