2026-07-01 15:41:27 +02:00
""" Service layer for the Mail plugin: encryption, IMAP sync, SMTP send, rules, vacation, PGP. """
from __future__ import annotations
import base64
import json
2026-07-16 23:08:37 +02:00
import logging
2026-07-15 18:43:38 +02:00
import mimetypes
2026-07-01 15:41:27 +02:00
import os
import re
import uuid
from datetime import UTC , datetime , timedelta
from email import message_from_bytes
from email . message import EmailMessage
from email . utils import formataddr , formatdate , make_msgid
2026-07-15 18:43:38 +02:00
import aiofiles
2026-07-01 15:41:27 +02:00
import aioimaplib
import aiosmtplib
import nh3
import pgpy
from cryptography . fernet import Fernet
from cryptography . hazmat . primitives import hashes
from cryptography . hazmat . primitives . kdf . pbkdf2 import PBKDF2HMAC
2026-07-20 13:17:07 +02:00
from sqlalchemy import and_ , func , or_ , select , text
2026-07-01 15:41:27 +02:00
from sqlalchemy . ext . asyncio import AsyncSession
2026-07-15 18:43:38 +02:00
from app . config import settings
2026-07-15 20:41:57 +02:00
from app . core . notifications import create_notification
2026-07-01 15:41:27 +02:00
from app . plugins . builtins . mail . models import (
Mail ,
MailAccount ,
MailAttachment ,
MailFolder ,
MailLabel ,
MailLabelAssignment ,
MailRule ,
MailSignature ,
MailTemplate ,
VacationSentLog ,
)
2026-07-16 23:08:37 +02:00
logger = logging . getLogger ( __name__ )
2026-07-15 18:43:38 +02:00
# ─── Attachment Storage Helpers ───
MAX_ATTACHMENT_SIZE = 25 * 1024 * 1024 # 25 MB
2026-07-16 23:30:13 +02:00
def _decode_mime_filename ( filename : str ) - > str :
""" Decode MIME-encoded filename, handling =?charset?Q?...?= and =?charset?B?...?= patterns. """
if not filename :
return " attachment "
# If no MIME encoding pattern, return as-is
if " =? " not in filename :
return filename
try :
from email . header import decode_header , make_header
return str ( make_header ( decode_header ( filename ) ) )
except Exception :
# Fallback: manually decode Q-encoding if decode_header fails
# This handles cases where the email parser partially processed the filename
try :
import re
def decode_q ( match ) :
charset , encoding , encoded = match . group ( 1 ) , match . group ( 2 ) . upper ( ) , match . group ( 3 )
if encoding == ' B ' :
import base64
decoded = base64 . b64decode ( encoded ) . decode ( charset or ' utf-8 ' , errors = ' replace ' )
else : # Q encoding
decoded = encoded . replace ( ' _ ' , ' ' )
decoded = re . sub ( r ' =([0-9A-Fa-f] {2} ) ' , lambda m : chr ( int ( m . group ( 1 ) , 16 ) ) , decoded )
decoded = decoded . encode ( ' latin-1 ' ) . decode ( charset or ' utf-8 ' , errors = ' replace ' )
return decoded
return re . sub ( r ' = \ ?([^?]+) \ ?([BbQq]) \ ?([^?]*) \ ?= ' , decode_q , filename )
except Exception :
return filename
2026-07-15 18:43:38 +02:00
def _sanitize_filename ( filename : str ) - > str :
""" Sanitize a filename to prevent path traversal attacks. """
# Remove any path components — keep only the basename
filename = os . path . basename ( filename or " attachment " )
# Replace potentially dangerous characters
filename = re . sub ( r " [^a-zA-Z0-9._-] " , " _ " , filename )
# Ensure non-empty
if not filename :
filename = " attachment "
# Limit length
if len ( filename ) > 200 :
name , ext = os . path . splitext ( filename )
filename = name [ : 200 - len ( ext ) ] + ext
return filename
def _attachment_storage_path ( mail_id : uuid . UUID , filename : str ) - > str :
""" Build the on-disk storage path for a mail attachment. """
safe_name = _sanitize_filename ( filename )
return os . path . join (
settings . storage_path ,
" mail_attachments " ,
str ( mail_id ) ,
safe_name ,
)
async def _save_attachment_to_storage (
mail_id : uuid . UUID , filename : str , content : bytes
) - > str :
""" Save attachment content to disk and return the storage path. """
storage_path = _attachment_storage_path ( mail_id , filename )
os . makedirs ( os . path . dirname ( storage_path ) , exist_ok = True )
async with aiofiles . open ( storage_path , " wb " ) as f :
await f . write ( content )
return storage_path
def attachment_to_response ( att : MailAttachment ) - > dict :
""" Convert a MailAttachment ORM object to a response dict. """
return {
" id " : str ( att . id ) ,
" mail_id " : str ( att . mail_id ) ,
" filename " : att . filename ,
" mime_type " : att . mime_type ,
" size_bytes " : att . size_bytes ,
" size " : att . size_bytes , # alias for frontend compatibility
" content_id " : att . content_id ,
" is_inline " : bool ( att . content_id ) ,
" dms_file_id " : str ( att . dms_file_id ) if att . dms_file_id else None ,
}
2026-07-01 15:41:27 +02:00
# ─── AES-256 Encryption (Fernet) ───
MAIL_ENCRYPTION_KEY = os . environ . get ( " MAIL_ENCRYPTION_KEY " , " leocrm-mail-encryption-key-2024 " )
def _derive_key ( password : str , salt : bytes = b " leocrm-mail-salt " ) - > bytes :
""" Derive a 32-byte Fernet key from a password using PBKDF2. """
kdf = PBKDF2HMAC (
algorithm = hashes . SHA256 ( ) ,
length = 32 ,
salt = salt ,
iterations = 480000 ,
)
return base64 . urlsafe_b64encode ( kdf . derive ( password . encode ( ) ) )
_fernet = Fernet ( _derive_key ( MAIL_ENCRYPTION_KEY ) )
def encrypt_password ( plaintext : str ) - > str :
""" Encrypt a password using AES-256 (Fernet). Returns base64 ciphertext. """
return _fernet . encrypt ( plaintext . encode ( ) ) . decode ( )
def decrypt_password ( ciphertext : str ) - > str :
""" Decrypt a password encrypted with encrypt_password. """
return _fernet . decrypt ( ciphertext . encode ( ) ) . decode ( )
# ─── HTML Sanitization (F-MAIL: no script tags) ───
def sanitize_html ( raw_html : str ) - > str :
""" Sanitize HTML using nh3 — removes script tags and dangerous attributes. """
if not raw_html :
return " "
return nh3 . clean (
raw_html ,
tags = {
" a " ,
" b " ,
" br " ,
" div " ,
" em " ,
" h1 " ,
" h2 " ,
" h3 " ,
" h4 " ,
" h5 " ,
" h6 " ,
" hr " ,
" i " ,
" img " ,
" li " ,
" ol " ,
" p " ,
" span " ,
" strong " ,
" table " ,
" tbody " ,
" td " ,
" th " ,
" thead " ,
" tr " ,
" u " ,
" ul " ,
" blockquote " ,
" code " ,
" pre " ,
" font " ,
" center " ,
} ,
attributes = {
" a " : { " href " , " title " , " target " } ,
" img " : { " src " , " alt " , " width " , " height " } ,
" span " : { " style " } ,
" div " : { " style " } ,
" font " : { " color " , " size " , " face " } ,
" p " : { " style " } ,
" td " : { " style " } ,
" th " : { " style " } ,
} ,
)
2026-07-15 20:41:57 +02:00
# ─── IMAP Quota Parser ───
def _parse_imap_quota_response ( response ) - > int | None :
""" Parse an IMAP GETQUOTAROOT response and return usage percentage.
Looks for lines like:
* QUOTA " INBOX " (STORAGE 12345 67890)
where 12345 is used and 67890 is limit.
Returns the usage percentage as an int, or None if parsing fails.
"""
try :
lines = response . lines if hasattr ( response , " lines " ) else response
for line in lines :
if isinstance ( line , ( bytes , bytearray ) ) :
line = line . decode ( " utf-8 " , errors = " replace " )
if not isinstance ( line , str ) :
continue
if " QUOTA " not in line . upper ( ) :
continue
# Extract the parenthesized storage values
# Pattern: (STORAGE <used> <limit>)
match = re . search ( r " \ (STORAGE \ s+( \ d+) \ s+( \ d+) \ ) " , line , re . IGNORECASE )
if match :
used = int ( match . group ( 1 ) )
limit = int ( match . group ( 2 ) )
if limit > 0 :
return int ( ( used / limit ) * 100 )
except Exception :
pass
return None
2026-07-01 15:41:27 +02:00
# ─── Mail Account Service ───
async def create_mail_account (
db : AsyncSession , * , tenant_id : uuid . UUID , user_id : uuid . UUID , data : dict
) - > MailAccount :
""" Create a new mail account with encrypted password. """
account = MailAccount (
tenant_id = tenant_id ,
user_id = user_id ,
email_address = data [ " email_address " ] ,
display_name = data . get ( " display_name " , " " ) ,
imap_host = data [ " imap_host " ] ,
imap_port = data . get ( " imap_port " , 993 ) ,
imap_ssl = data . get ( " imap_ssl " , True ) ,
smtp_host = data [ " smtp_host " ] ,
smtp_port = data . get ( " smtp_port " , 587 ) ,
smtp_tls = data . get ( " smtp_tls " , True ) ,
2026-07-15 15:59:04 +02:00
username = data . get ( " username " ) or data [ " email_address " ] ,
2026-07-01 15:41:27 +02:00
encrypted_password = encrypt_password ( data [ " password " ] ) ,
is_shared = data . get ( " is_shared " , False ) ,
is_active = True ,
2026-07-17 23:17:52 +02:00
sent_folder_imap_name = data . get ( " sent_folder_imap_name " ) ,
drafts_folder_imap_name = data . get ( " drafts_folder_imap_name " ) ,
spam_folder_imap_name = data . get ( " spam_folder_imap_name " ) ,
trash_folder_imap_name = data . get ( " trash_folder_imap_name " ) ,
2026-07-01 15:41:27 +02:00
)
db . add ( account )
await db . flush ( )
2026-07-15 15:59:04 +02:00
# Create INBOX first so subfolders can reference it as parent
inbox_folder = MailFolder (
tenant_id = tenant_id ,
account_id = account . id ,
name = " Posteingang " ,
imap_name = " INBOX " ,
is_standard = True ,
)
db . add ( inbox_folder )
await db . flush ( )
# Create standard subfolders under INBOX (IMAP server uses '.' delimiter)
2026-07-01 15:41:27 +02:00
for fname , imap_name in [
2026-07-15 15:59:04 +02:00
( " Gesendet " , " INBOX.Sent " ) ,
( " Entwürfe " , " INBOX.Drafts " ) ,
2026-07-20 13:17:07 +02:00
( " Papierkorb " , " INBOX.Trash " ) ,
2026-07-15 15:59:04 +02:00
( " Spam " , " INBOX.spam " ) ,
2026-07-01 15:41:27 +02:00
] :
folder = MailFolder (
tenant_id = tenant_id ,
account_id = account . id ,
name = fname ,
imap_name = imap_name ,
2026-07-15 15:59:04 +02:00
parent_id = inbox_folder . id ,
2026-07-01 15:41:27 +02:00
is_standard = True ,
)
db . add ( folder )
await db . flush ( )
return account
async def update_mail_account ( db : AsyncSession , account : MailAccount , data : dict ) - > MailAccount :
""" Update a mail account, encrypting password if changed. """
2026-07-15 15:59:04 +02:00
field_map = {
" email " : " email_address " ,
" email_address " : " email_address " ,
" display_name " : " display_name " ,
" imap_host " : " imap_host " ,
" imap_port " : " imap_port " ,
" imap_ssl " : " imap_ssl " ,
" smtp_host " : " smtp_host " ,
" smtp_port " : " smtp_port " ,
" smtp_tls " : " smtp_tls " ,
" username " : " username " ,
" is_shared " : " is_shared " ,
" is_active " : " is_active " ,
2026-07-17 23:17:52 +02:00
" sent_folder_imap_name " : " sent_folder_imap_name " ,
" drafts_folder_imap_name " : " drafts_folder_imap_name " ,
" spam_folder_imap_name " : " spam_folder_imap_name " ,
" trash_folder_imap_name " : " trash_folder_imap_name " ,
2026-07-15 15:59:04 +02:00
}
for api_field , model_field in field_map . items ( ) :
if api_field in data and data [ api_field ] is not None :
setattr ( account , model_field , data [ api_field ] )
2026-07-01 15:41:27 +02:00
if " password " in data and data [ " password " ] is not None :
account . encrypted_password = encrypt_password ( data [ " password " ] )
await db . flush ( )
await db . refresh ( account )
return account
async def get_account_password ( account : MailAccount ) - > str :
""" Decrypt and return the account password (internal use only). """
return decrypt_password ( account . encrypted_password )
def account_to_response ( account : MailAccount ) - > dict :
""" Convert MailAccount to response dict, NEVER including password. """
return {
" id " : str ( account . id ) ,
2026-07-15 15:59:04 +02:00
" email " : account . email_address ,
2026-07-01 15:41:27 +02:00
" email_address " : account . email_address ,
" display_name " : account . display_name ,
" imap_host " : account . imap_host ,
" imap_port " : account . imap_port ,
" imap_ssl " : account . imap_ssl ,
" smtp_host " : account . smtp_host ,
" smtp_port " : account . smtp_port ,
" smtp_tls " : account . smtp_tls ,
" username " : account . username ,
" is_shared " : account . is_shared ,
" is_active " : account . is_active ,
2026-07-17 23:17:52 +02:00
" sent_folder_imap_name " : account . sent_folder_imap_name ,
" drafts_folder_imap_name " : account . drafts_folder_imap_name ,
" spam_folder_imap_name " : account . spam_folder_imap_name ,
" trash_folder_imap_name " : account . trash_folder_imap_name ,
2026-07-01 15:41:27 +02:00
" created_at " : account . created_at ,
" updated_at " : account . updated_at ,
}
# ─── IMAP Sync Service (F-MAIL-01) ───
2026-07-15 15:59:04 +02:00
# German display names for standard IMAP folders.
# Keys are full IMAP paths (with dot delimiter) and also bare leaf names.
2026-07-15 14:36:45 +02:00
IMAP_FOLDER_NAME_MAP = {
" INBOX " : " Posteingang " ,
2026-07-15 15:59:04 +02:00
" INBOX.Sent " : " Gesendet " ,
" INBOX.Drafts " : " Entwürfe " ,
" INBOX.Trash " : " Papierkorb " ,
" INBOX.Archive " : " Archiv " ,
" INBOX.spam " : " Spam " ,
" INBOX.Spam " : " Spam " ,
# Bare names (fallback for servers that don't nest under INBOX)
2026-07-15 14:36:45 +02:00
" Sent " : " Gesendet " ,
" Sent Items " : " Gesendet " ,
" Sent Mail " : " Gesendet " ,
" Drafts " : " Entwürfe " ,
" Draft " : " Entwürfe " ,
" Spam " : " Spam " ,
" Junk " : " Spam " ,
" Junk Email " : " Spam " ,
" Junk E-mail " : " Spam " ,
" Trash " : " Papierkorb " ,
" Deleted " : " Papierkorb " ,
" Deleted Items " : " Papierkorb " ,
2026-07-15 15:59:04 +02:00
" Archive " : " Archiv " ,
2026-07-15 14:36:45 +02:00
}
# Standard IMAP folders that are always considered "standard"
STANDARD_IMAP_FOLDERS = {
2026-07-15 15:59:04 +02:00
" INBOX " ,
" INBOX.Sent " , " INBOX.Drafts " , " INBOX.Trash " , " INBOX.Archive " ,
" INBOX.spam " , " INBOX.Spam " ,
" Sent " , " Sent Items " , " Sent Mail " ,
2026-07-15 14:36:45 +02:00
" Drafts " , " Draft " ,
" Spam " , " Junk " , " Junk Email " , " Junk E-mail " ,
" Trash " , " Deleted " , " Deleted Items " ,
2026-07-15 15:59:04 +02:00
" Archive " ,
2026-07-15 14:36:45 +02:00
}
2026-07-17 23:02:53 +02:00
MAX_EMAILS_PER_FOLDER = 2000
2026-07-15 14:36:45 +02:00
def _get_german_folder_name ( imap_name : str ) - > str :
""" Return the German display name for a standard IMAP folder, or the original name. """
if imap_name in IMAP_FOLDER_NAME_MAP :
return IMAP_FOLDER_NAME_MAP [ imap_name ]
2026-07-15 15:59:04 +02:00
# Try the leaf component (after last dot) for unknown nested folders
leaf = imap_name . rsplit ( " . " , 1 ) [ - 1 ] if " . " in imap_name else imap_name
if leaf in IMAP_FOLDER_NAME_MAP :
return IMAP_FOLDER_NAME_MAP [ leaf ]
2026-07-15 14:36:45 +02:00
return imap_name
2026-07-17 22:50:02 +02:00
def _parse_imap_list_response ( response ) - > tuple [ list [ tuple [ str , str ] ] , str ] :
2026-07-15 15:59:04 +02:00
""" Parse IMAP LIST response into list of (flags, folder_name) tuples.
Handles both " / " and " . " delimiters. The IMAP LIST response format is:
* LIST ( \\ HasChildren) " . " " INBOX "
* LIST ( \\ HasNoChildren) " . " " INBOX.Sent "
2026-07-17 22:50:02 +02:00
Returns (folders, delimiter) where delimiter is the hierarchy separator
detected from the LIST response (defaults to ' . ' if not found).
2026-07-15 15:59:04 +02:00
"""
2026-07-15 14:36:45 +02:00
folders : list [ tuple [ str , str ] ] = [ ]
2026-07-17 22:50:02 +02:00
delimiter = ' . '
2026-07-15 14:36:45 +02:00
lines = response . lines if hasattr ( response , ' lines ' ) else response
for line in lines :
if isinstance ( line , ( bytes , bytearray ) ) :
text = line . decode ( ' utf-8 ' , errors = ' replace ' )
elif isinstance ( line , str ) :
text = line
else :
continue
if ' LIST ' not in text :
continue
2026-07-15 15:59:04 +02:00
# Extract quoted segments — the delimiter is the first quoted string,
# the folder name is the second.
2026-07-15 14:36:45 +02:00
parts = text . split ( ' " ' )
if len ( parts ) > = 4 :
delimiter = parts [ 1 ]
folder_name = parts [ 3 ]
flags = parts [ 0 ] if parts [ 0 ] else ' '
folders . append ( ( flags , folder_name ) )
elif len ( parts ) > = 2 :
folder_name = parts [ - 2 ] if len ( parts ) > = 2 else ' '
if folder_name :
folders . append ( ( ' ' , folder_name ) )
2026-07-17 22:50:02 +02:00
return folders , delimiter
2026-07-15 14:36:45 +02:00
def _build_folder_hierarchy (
imap_folders : list [ tuple [ str , str ] ] ,
account_id : uuid . UUID ,
tenant_id : uuid . UUID ,
existing_folders : dict [ str , MailFolder ] ,
2026-07-17 22:50:02 +02:00
delimiter : str = ' . ' ,
2026-07-17 23:17:52 +02:00
folder_mapping : dict [ str , str | None ] | None = None ,
2026-07-15 14:36:45 +02:00
) - > list [ MailFolder ] :
""" Create or update MailFolder records from IMAP LIST response.
2026-07-15 15:59:04 +02:00
2026-07-17 22:50:02 +02:00
Handles delimiter-separated hierarchies (e.g. INBOX.Sent → parent=INBOX).
2026-07-15 15:59:04 +02:00
Updates existing folders in-place (name, is_standard, parent_id) so
that stale DB records with wrong imap_name values get corrected.
2026-07-15 14:36:45 +02:00
"""
result : list [ MailFolder ] = [ ]
2026-07-15 15:59:04 +02:00
2026-07-17 23:17:52 +02:00
# Build reverse mapping: imap_name -> standard type (sent/drafts/spam/trash)
mapping_by_imap : dict [ str , str ] = { }
if folder_mapping :
for std_type , imap_name_val in folder_mapping . items ( ) :
if imap_name_val :
mapping_by_imap [ imap_name_val ] = std_type
2026-07-15 15:59:04 +02:00
# First pass: create or update folder records
2026-07-15 14:36:45 +02:00
for flags , imap_name in imap_folders :
if not imap_name :
continue
2026-07-15 15:59:04 +02:00
2026-07-15 14:36:45 +02:00
display_name = _get_german_folder_name ( imap_name )
is_standard = imap_name in STANDARD_IMAP_FOLDERS
2026-07-15 15:59:04 +02:00
2026-07-17 23:17:52 +02:00
# If account has explicit folder mapping, mark mapped folders as standard
if imap_name in mapping_by_imap :
is_standard = True
std_type = mapping_by_imap [ imap_name ]
if std_type == " sent " :
display_name = " Gesendet "
elif std_type == " drafts " :
display_name = " Entwürfe "
elif std_type == " spam " :
display_name = " Spam "
elif std_type == " trash " :
display_name = " Papierkorb "
2026-07-15 14:36:45 +02:00
if imap_name in existing_folders :
folder = existing_folders [ imap_name ]
folder . name = display_name
folder . is_standard = is_standard
result . append ( folder )
else :
folder = MailFolder (
tenant_id = tenant_id ,
account_id = account_id ,
name = display_name ,
imap_name = imap_name ,
is_standard = is_standard ,
)
result . append ( folder )
2026-07-15 15:59:04 +02:00
2026-07-15 14:36:45 +02:00
# Second pass: set parent_id based on IMAP hierarchy
folder_by_imap_name = { f . imap_name : f for f in result }
for folder in result :
if delimiter in folder . imap_name :
parts = folder . imap_name . split ( delimiter )
parent_imap = delimiter . join ( parts [ : - 1 ] )
if parent_imap in folder_by_imap_name :
parent = folder_by_imap_name [ parent_imap ]
2026-07-15 15:59:04 +02:00
folder . parent_id = parent . id # may be None for new folders; fixed after flush
else :
folder . parent_id = None
else :
folder . parent_id = None
2026-07-15 14:36:45 +02:00
return result
2026-07-20 12:01:16 +02:00
async def imap_sync_folder (
db : AsyncSession ,
folder_id : uuid . UUID ,
tenant_id : uuid . UUID ,
) - > dict :
2026-07-20 12:12:53 +02:00
""" Incremental sync: only fetch NEW mails since the highest known UID.
Like real mail clients: checks for new UIDs only, doesn ' t re-fetch everything.
Also checks for deleted mails on the server (vanished UIDs).
"""
2026-07-20 12:01:16 +02:00
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 :
return { " synced " : 0 , " error " : " Folder not found " }
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 :
return { " synced " : 0 , " error " : " Account not found " }
if not account . is_active :
return { " synced " : 0 , " error " : " Account is not active " }
2026-07-20 12:12:53 +02:00
# Get the highest known UID in this folder from DB
max_uid_result = (
await db . execute (
select ( func . max ( Mail . imap_uid ) ) . where (
and_ (
Mail . folder_id == folder_id ,
Mail . tenant_id == tenant_id ,
Mail . imap_uid . is_not ( None ) ,
)
)
)
) . scalar ( )
2026-07-20 12:01:16 +02:00
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 )
select_resp = await client . select ( folder . imap_name )
if select_resp . result != ' OK ' :
return { " synced " : 0 , " error " : f " Cannot select folder { folder . imap_name } " }
2026-07-20 12:12:53 +02:00
# Incremental: only search for UIDs greater than our highest known UID
if max_uid_result :
try :
max_uid_int = int ( max_uid_result )
search_resp = await client . uid_search ( f ' UID { max_uid_int + 1 } :* ' )
except ( ValueError , TypeError ) :
search_resp = await client . uid_search ( ' ALL ' )
else :
# First sync — get last 50
search_resp = await client . uid_search ( ' ALL ' )
2026-07-20 12:01:16 +02:00
uids_raw = search_resp [ 1 ] [ 0 ] if search_resp [ 1 ] and search_resp [ 1 ] [ 0 ] else b ' '
if isinstance ( uids_raw , ( bytes , bytearray ) ) :
uids = uids_raw . split ( )
else :
uids = [ ]
2026-07-20 12:12:53 +02:00
# First sync: limit to last 50
if not max_uid_result and len ( uids ) > MAX_EMAILS_PER_FOLDER :
2026-07-20 12:01:16 +02:00
uids = uids [ - MAX_EMAILS_PER_FOLDER : ]
synced_count = 0
for uid in uids :
uid_str = uid . decode ( ) if isinstance ( uid , bytes ) else str ( uid )
2026-07-20 12:12:53 +02:00
# Skip if we already have this UID (can happen with UID * search)
2026-07-20 12:01:16 +02:00
existing_mail = (
await db . execute (
select ( Mail ) . where (
and_ (
Mail . account_id == account . id ,
Mail . folder_id == folder . id ,
Mail . imap_uid == uid_str ,
Mail . tenant_id == tenant_id ,
)
)
)
) . scalar_one_or_none ( )
2026-07-20 12:59:19 +02:00
# Also check by message_id — mail may have been moved to Trash by IMAP server
if not existing_mail :
# Fetch headers first to get Message-ID
fetch_hdr = await client . uid ( ' fetch ' , uid_str , ' (BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)]) ' )
hdr_raw = None
for line in ( fetch_hdr . lines if hasattr ( fetch_hdr , ' lines ' ) else fetch_hdr ) :
if isinstance ( line , bytearray ) :
hdr_raw = bytes ( line )
break
if hdr_raw :
hdr_msg = message_from_bytes ( hdr_raw )
peek_msg_id = hdr_msg . get ( " Message-ID " , " " )
if peek_msg_id :
existing_mail = (
await db . execute (
select ( Mail ) . where (
and_ (
Mail . account_id == account . id ,
Mail . message_id == peek_msg_id ,
Mail . tenant_id == tenant_id ,
)
)
)
) . scalar_one_or_none ( )
2026-07-20 12:01:16 +02:00
if existing_mail :
2026-07-20 13:46:48 +02:00
# If mail has deleted_at but is still on the IMAP server,
2026-07-20 13:56:30 +02:00
# the IMAP delete failed.
# Only restore if the mail is in the SAME folder we're syncing.
# If the mail was moved to Trash in DB (folder_id=Trash) but is
# still in INBOX on IMAP, DON'T restore to INBOX — keep it
# in Trash and let the sync queue retry the IMAP MOVE.
2026-07-20 12:01:16 +02:00
if existing_mail . deleted_at is not None :
2026-07-20 13:56:30 +02:00
if existing_mail . folder_id == folder . id :
# Mail is in the right folder on IMAP — restore
existing_mail . deleted_at = None
logger . info ( " imap_sync_folder: restored mail %s (still on IMAP server in correct folder) " , existing_mail . id )
else :
# Mail is in a different folder in DB — IMAP move failed
# Don't restore, don't change folder_id — keep in Trash
logger . info ( " imap_sync_folder: skipping mail %s (deleted, in folder %s but found in %s on IMAP, move failed) " , existing_mail . id , existing_mail . folder_id , folder . imap_name )
continue
2026-07-20 12:59:19 +02:00
# Update folder_id and imap_uid if mail moved to a different folder
if existing_mail . folder_id != folder . id :
existing_mail . folder_id = folder . id
existing_mail . imap_uid = uid_str
elif not existing_mail . imap_uid :
existing_mail . imap_uid = uid_str
2026-07-20 12:01:16 +02:00
continue
# Fetch and parse the email
fetch_resp = await client . uid ( ' fetch ' , uid_str , ' (RFC822) ' )
raw_email = None
for line in ( fetch_resp . lines if hasattr ( fetch_resp , ' lines ' ) else fetch_resp ) :
if isinstance ( line , bytearray ) :
raw_email = bytes ( line )
break
if raw_email is None :
continue
if isinstance ( raw_email , str ) :
raw_email = raw_email . encode ( )
msg = message_from_bytes ( raw_email )
body_text = " "
body_html = " "
attachments = [ ]
if msg . is_multipart ( ) :
for part in msg . walk ( ) :
ct = part . get_content_type ( )
if ct == " text/plain " :
payload = part . get_payload ( decode = True )
if payload :
body_text = payload . decode ( " utf-8 " , errors = " replace " )
elif ct == " text/html " :
payload = part . get_payload ( decode = True )
if payload :
body_html = payload . decode ( " utf-8 " , errors = " replace " )
elif part . get_filename ( ) :
payload_bytes = part . get_payload ( decode = True ) or b " "
attachments . append ( {
" filename " : part . get_filename ( ) ,
" mime_type " : ct ,
" size " : len ( payload_bytes ) ,
" content " : payload_bytes ,
} )
else :
ct = msg . get_content_type ( )
payload = msg . get_payload ( decode = True )
if payload :
decoded = payload . decode ( " utf-8 " , errors = " replace " )
if ct == " text/html " :
body_html = decoded
else :
body_text = decoded
from email . header import decode_header , make_header
def _decode_mime_header ( value : str ) - > str :
if not value :
return " "
try :
return str ( make_header ( decode_header ( value ) ) )
except Exception :
return value
raw_msg_id = msg . get ( " Message-ID " )
if raw_msg_id :
message_id = raw_msg_id
else :
message_id = f " generated- { account . id } - { folder . id } - { uid_str } "
subject = _decode_mime_header ( msg . get ( " Subject " , " " ) )
from_addr = _decode_mime_header ( msg . get ( " From " , " " ) )
to_addrs = _decode_mime_header ( msg . get ( " To " , " " ) )
cc_addrs = _decode_mime_header ( msg . get ( " Cc " , " " ) )
refs = msg . get ( " References " , " " )
in_reply_to = msg . get ( " In-Reply-To " )
date_str = msg . get ( " Date " , " " )
received_at = datetime . now ( UTC )
if date_str :
try :
from email . utils import parsedate_to_datetime
parsed = parsedate_to_datetime ( date_str )
if parsed :
received_at = parsed . astimezone ( UTC ) if parsed . tzinfo else parsed . replace ( tzinfo = UTC )
except Exception :
pass
thread_id = _compute_thread_id ( message_id , refs , in_reply_to )
mail = Mail (
tenant_id = tenant_id ,
account_id = account . id ,
folder_id = folder . id ,
imap_uid = uid_str ,
message_id = message_id ,
thread_id = thread_id ,
in_reply_to = in_reply_to ,
references_header = refs ,
subject = subject ,
from_address = from_addr ,
to_addresses = to_addrs ,
cc_addresses = cc_addrs ,
body_text = body_text ,
body_html = body_html ,
body_html_sanitized = sanitize_html ( body_html ) ,
has_attachments = len ( attachments ) > 0 ,
size_bytes = len ( raw_email ) ,
received_at = received_at ,
)
db . add ( mail )
await db . flush ( )
synced_count + = 1
# Save attachments
for att_data in attachments :
try :
raw_filename = att_data [ " filename " ] or " attachment "
decoded_filename = _decode_mime_filename ( raw_filename )
storage_path = await _save_attachment_to_storage (
mail . id , decoded_filename , att_data [ " content " ]
)
attachment = MailAttachment (
tenant_id = tenant_id ,
mail_id = mail . id ,
filename = _sanitize_filename ( decoded_filename ) ,
mime_type = att_data [ " mime_type " ] ,
size_bytes = att_data [ " size " ] ,
storage_path = storage_path ,
)
db . add ( attachment )
except Exception as att_err :
logger . warning ( f " Failed to save attachment for mail { mail . id } : { att_err } " )
continue
2026-07-20 12:12:53 +02:00
# Update folder counts
total = (
await db . execute (
select ( func . count ( ) ) . select_from ( Mail ) . where (
and_ (
Mail . folder_id == folder_id ,
Mail . tenant_id == tenant_id ,
Mail . deleted_at . is_ ( None ) ,
)
)
)
) . scalar ( ) or 0
unread = (
await db . execute (
select ( func . count ( ) ) . select_from ( Mail ) . where (
and_ (
Mail . folder_id == folder_id ,
Mail . tenant_id == tenant_id ,
Mail . deleted_at . is_ ( None ) ,
2026-07-20 13:17:07 +02:00
Mail . is_seen . is_ ( False ) ,
2026-07-20 12:12:53 +02:00
)
)
)
) . scalar ( ) or 0
folder . total_count = total
folder . unread_count = unread
2026-07-20 12:01:16 +02:00
await db . flush ( )
logger . info ( " imap_sync_folder: synced %d new mail(s) for folder %s " , synced_count , folder . imap_name )
return { " synced " : synced_count }
except Exception as exc :
logger . warning ( " imap_sync_folder: failed for folder %s : %s " , folder_id , exc )
return { " synced " : 0 , " error " : str ( exc ) }
finally :
if client is not None :
try :
await client . logout ( )
except Exception :
pass
2026-07-01 15:41:27 +02:00
async def imap_sync_account (
db : AsyncSession ,
account_id : uuid . UUID ,
tenant_id : uuid . UUID ,
) - > dict :
""" Sync mail folders and messages from IMAP server.
2026-07-15 14:36:45 +02:00
Syncs ALL folders from the IMAP server (not just INBOX).
For each folder, fetches the last MAX_EMAILS_PER_FOLDER emails
(sorted by date descending) to avoid timeouts on large mailboxes.
Creates/updates mail_folders records with German display names.
2026-07-01 15:41:27 +02:00
"""
account = (
await db . execute (
select ( MailAccount ) . where (
and_ ( MailAccount . id == account_id , MailAccount . tenant_id == tenant_id )
)
)
) . scalar_one_or_none ( )
if not account :
return { " synced " : 0 , " error " : " Account not found " }
2026-07-15 20:41:57 +02:00
if not account . is_active :
try :
await create_notification (
db , account . tenant_id , account . user_id ,
" mail_account " ,
" Mail-Account deaktiviert " ,
f " Account { account . email_address } ist deaktiviert und wird nicht synchronisiert. " ,
)
await db . flush ( )
except Exception :
pass
return { " synced " : 0 , " error " : " Account is not active " }
2026-07-01 15:41:27 +02:00
password = await get_account_password ( account )
2026-07-15 20:41:57 +02:00
# ── IMAP connection ──
2026-07-01 15:41:27 +02:00
try :
client = aioimaplib . IMAP4_SSL ( host = account . imap_host , port = account . imap_port )
await client . wait_hello_from_server ( )
2026-07-15 20:41:57 +02:00
except Exception as e :
try :
await create_notification (
db , account . tenant_id , account . user_id ,
" mail_error " ,
" IMAP-Verbindung fehlgeschlagen " ,
f " Account { account . email_address } : { e } " ,
)
await db . flush ( )
except Exception :
pass
return { " synced " : 0 , " error " : f " IMAP connection failed: { e } " }
# ── IMAP login ──
try :
2026-07-01 15:41:27 +02:00
await client . login ( account . username , password )
2026-07-15 20:41:57 +02:00
except Exception as e :
try :
await create_notification (
db , account . tenant_id , account . user_id ,
" mail_auth " ,
" IMAP-Login fehlgeschlagen " ,
f " Account { account . email_address } : Passwort oder Anmeldedaten prüfen " ,
)
await db . flush ( )
except Exception :
pass
try :
await client . logout ( )
except Exception :
pass
return { " synced " : 0 , " error " : f " IMAP login failed: { e } " }
2026-07-01 15:41:27 +02:00
2026-07-15 20:41:57 +02:00
# ── Quota check (non-critical, not all servers support QUOTA) ──
try :
quota_resp = await client . getquotaroot ( ' INBOX ' )
usage_pct = _parse_imap_quota_response ( quota_resp )
if usage_pct is not None and usage_pct > 80 :
if usage_pct > 95 :
quota_title = " Postfach voll – keine neuen Mails empfangbar "
else :
quota_title = " Postfach fast voll "
try :
await create_notification (
db , account . tenant_id , account . user_id ,
" mail_quota " ,
quota_title ,
f " Account { account . email_address } : { usage_pct } % belegt " ,
)
await db . flush ( )
except Exception :
pass
except Exception :
pass
try :
2026-07-15 14:36:45 +02:00
# 1) LIST all folders from IMAP server
list_response = await client . list ( ' " " ' , ' " * " ' )
2026-07-17 22:50:02 +02:00
imap_folders , imap_delimiter = _parse_imap_list_response ( list_response )
2026-07-15 14:36:45 +02:00
2026-07-15 15:59:04 +02:00
# If LIST returned nothing, fall back to standard folders (dot-delimited)
2026-07-15 14:36:45 +02:00
if not imap_folders :
imap_folders = [
( ' ' , ' INBOX ' ) ,
2026-07-15 15:59:04 +02:00
( ' ' , ' INBOX.Sent ' ) ,
( ' ' , ' INBOX.Drafts ' ) ,
( ' ' , ' INBOX.spam ' ) ,
( ' ' , ' INBOX.Trash ' ) ,
2026-07-15 14:36:45 +02:00
]
# 2) Load existing folders from DB for this account
existing_db_folders = (
await db . execute (
select ( MailFolder ) . where (
and_ ( MailFolder . account_id == account . id , MailFolder . tenant_id == tenant_id )
2026-07-01 15:41:27 +02:00
)
)
2026-07-15 14:36:45 +02:00
) . scalars ( ) . all ( )
existing_by_imap : dict [ str , MailFolder ] = {
f . imap_name : f for f in existing_db_folders
}
2026-07-15 15:59:04 +02:00
# 2a) Migrate stale folder names: if DB has 'Sent' but IMAP returns
# 'INBOX.Sent', update the DB record's imap_name so it matches.
imap_names_from_server = { name for _ , name in imap_folders if name }
for db_folder in existing_db_folders :
if db_folder . imap_name not in imap_names_from_server :
2026-07-17 22:50:02 +02:00
# Try matching by leaf component using detected delimiter
leaf = db_folder . imap_name . rsplit ( imap_delimiter , 1 ) [ - 1 ]
2026-07-15 15:59:04 +02:00
for srv_name in imap_names_from_server :
2026-07-17 22:50:02 +02:00
srv_leaf = srv_name . rsplit ( imap_delimiter , 1 ) [ - 1 ]
2026-07-15 15:59:04 +02:00
if srv_leaf . lower ( ) == leaf . lower ( ) :
db_folder . imap_name = srv_name
existing_by_imap [ srv_name ] = db_folder
break
2026-07-15 14:36:45 +02:00
# 3) Create/update folders in DB
2026-07-17 23:17:52 +02:00
folder_mapping = {
" sent " : account . sent_folder_imap_name ,
" drafts " : account . drafts_folder_imap_name ,
" spam " : account . spam_folder_imap_name ,
" trash " : account . trash_folder_imap_name ,
}
2026-07-15 14:36:45 +02:00
db_folders = _build_folder_hierarchy (
2026-07-17 23:17:52 +02:00
imap_folders , account . id , tenant_id , existing_by_imap , imap_delimiter ,
folder_mapping = folder_mapping ,
2026-07-15 14:36:45 +02:00
)
for folder in db_folders :
if folder . id is None :
db . add ( folder )
2026-07-01 15:41:27 +02:00
await db . flush ( )
2026-07-15 15:59:04 +02:00
# 3a) Re-set parent_id now that new folders have IDs after flush
folder_by_imap_post_flush = { f . imap_name : f for f in db_folders }
for folder in db_folders :
2026-07-17 22:50:02 +02:00
if imap_delimiter in folder . imap_name :
parts = folder . imap_name . split ( imap_delimiter )
parent_imap = imap_delimiter . join ( parts [ : - 1 ] )
2026-07-15 15:59:04 +02:00
if parent_imap in folder_by_imap_post_flush :
parent = folder_by_imap_post_flush [ parent_imap ]
if parent . id :
folder . parent_id = parent . id
await db . flush ( )
2026-07-15 14:36:45 +02:00
# Build a map of imap_name -> folder_id for email sync
folder_by_imap = { f . imap_name : f for f in db_folders }
synced_count = 0
2026-07-15 20:41:57 +02:00
new_mails : list [ dict ] = [ ]
2026-07-15 14:36:45 +02:00
# 4) Sync emails for each folder (limit to last 50 per folder)
for imap_name , folder in folder_by_imap . items ( ) :
try :
2026-07-15 15:59:04 +02:00
# Select the folder on the IMAP server — use the raw IMAP
# name without extra quoting (aioimaplib handles it)
select_resp = await client . select ( imap_name )
2026-07-15 14:36:45 +02:00
if select_resp . result != ' OK ' :
continue
# Fetch UIDs and sort by date descending — get last 50
search_resp = await client . uid_search ( ' ALL ' )
uids_raw = search_resp [ 1 ] [ 0 ] if search_resp [ 1 ] and search_resp [ 1 ] [ 0 ] else b ' '
if isinstance ( uids_raw , ( bytes , bytearray ) ) :
uids = uids_raw . split ( )
else :
uids = [ ]
# Limit to last MAX_EMAILS_PER_FOLDER UIDs
# UIDs are monotonically increasing, so the last ones are the newest
if len ( uids ) > MAX_EMAILS_PER_FOLDER :
uids = uids [ - MAX_EMAILS_PER_FOLDER : ]
for uid in uids :
uid_str = uid . decode ( ) if isinstance ( uid , bytes ) else str ( uid )
fetch_resp = await client . uid ( ' fetch ' , uid_str , ' (RFC822) ' )
raw_email = None
for line in ( fetch_resp . lines if hasattr ( fetch_resp , ' lines ' ) else fetch_resp ) :
if isinstance ( line , bytearray ) :
raw_email = bytes ( line )
break
if raw_email is None :
continue
if isinstance ( raw_email , str ) :
raw_email = raw_email . encode ( )
msg = message_from_bytes ( raw_email )
body_text = " "
body_html = " "
attachments = [ ]
if msg . is_multipart ( ) :
for part in msg . walk ( ) :
ct = part . get_content_type ( )
if ct == " text/plain " :
payload = part . get_payload ( decode = True )
if payload :
body_text = payload . decode ( " utf-8 " , errors = " replace " )
elif ct == " text/html " :
payload = part . get_payload ( decode = True )
if payload :
body_html = payload . decode ( " utf-8 " , errors = " replace " )
elif part . get_filename ( ) :
2026-07-15 18:43:38 +02:00
payload_bytes = part . get_payload ( decode = True ) or b " "
content_id = part . get ( " Content-ID " , None )
if content_id :
content_id = content_id . strip ( " <> " )
2026-07-15 14:36:45 +02:00
attachments . append (
{
" filename " : part . get_filename ( ) ,
" mime_type " : ct ,
2026-07-15 18:43:38 +02:00
" size " : len ( payload_bytes ) ,
" content " : payload_bytes ,
" content_id " : content_id ,
2026-07-15 14:36:45 +02:00
}
)
else :
ct = msg . get_content_type ( )
payload = msg . get_payload ( decode = True )
if payload :
decoded = payload . decode ( " utf-8 " , errors = " replace " )
if ct == " text/html " :
body_html = decoded
else :
body_text = decoded
2026-07-16 09:40:59 +02:00
from email . header import decode_header , make_header
def _decode_mime_header ( value : str ) - > str :
""" Decode MIME encoded-words (=?UTF-8?Q?...?=) to readable text. """
if not value :
return " "
try :
return str ( make_header ( decode_header ( value ) ) )
except Exception :
return value
2026-07-17 22:50:02 +02:00
raw_msg_id = msg . get ( " Message-ID " )
if raw_msg_id :
message_id = raw_msg_id
else :
# Deterministic ID so re-syncs don't create duplicates
message_id = f " generated- { account . id } - { folder . id } - { uid_str } "
2026-07-16 09:40:59 +02:00
subject = _decode_mime_header ( msg . get ( " Subject " , " " ) )
from_addr = _decode_mime_header ( msg . get ( " From " , " " ) )
to_addrs = _decode_mime_header ( msg . get ( " To " , " " ) )
cc_addrs = _decode_mime_header ( msg . get ( " Cc " , " " ) )
2026-07-15 14:36:45 +02:00
refs = msg . get ( " References " , " " )
in_reply_to = msg . get ( " In-Reply-To " )
date_str = msg . get ( " Date " , " " )
# Parse date for received_at
received_at = datetime . now ( UTC )
if date_str :
try :
from email . utils import parsedate_to_datetime
parsed = parsedate_to_datetime ( date_str )
if parsed :
received_at = parsed . astimezone ( UTC ) if parsed . tzinfo else parsed . replace ( tzinfo = UTC )
except Exception :
pass
# Compute thread_id from References/In-Reply-To
thread_id = _compute_thread_id ( message_id , refs , in_reply_to )
2026-07-17 22:50:02 +02:00
# Dedup: first check by (account_id, folder_id, imap_uid),
# then fall back to (account_id, message_id) for mails synced
# before the imap_uid column existed.
2026-07-15 14:36:45 +02:00
existing_mail = (
await db . execute (
select ( Mail ) . where (
and_ (
Mail . account_id == account . id ,
2026-07-17 22:50:02 +02:00
Mail . folder_id == folder . id ,
Mail . imap_uid == uid_str ,
2026-07-15 14:36:45 +02:00
Mail . tenant_id == tenant_id ,
)
)
)
) . scalar_one_or_none ( )
2026-07-17 22:50:02 +02:00
if not existing_mail :
existing_mail = (
await db . execute (
select ( Mail ) . where (
and_ (
Mail . account_id == account . id ,
Mail . message_id == message_id ,
Mail . tenant_id == tenant_id ,
)
)
)
) . scalar_one_or_none ( )
2026-07-15 14:36:45 +02:00
if existing_mail :
2026-07-20 13:46:48 +02:00
# If mail has deleted_at but is still on the IMAP server,
2026-07-20 13:56:30 +02:00
# the IMAP delete failed.
# Only restore if the mail is in the SAME folder we're syncing.
# If the mail was moved to Trash in DB (folder_id=Trash) but is
# still in INBOX on IMAP, DON'T restore to INBOX — keep it
# in Trash and let the sync queue retry the IMAP MOVE.
2026-07-20 11:31:17 +02:00
if existing_mail . deleted_at is not None :
2026-07-20 13:56:30 +02:00
if existing_mail . folder_id == folder . id :
# Mail is in the right folder on IMAP — restore
existing_mail . deleted_at = None
logger . info ( " imap_sync_account: restored mail %s (still on IMAP server in correct folder) " , existing_mail . id )
else :
# Mail is in a different folder in DB — IMAP move failed
# Don't restore, don't change folder_id — keep in Trash
logger . info ( " imap_sync_account: skipping mail %s (deleted, in folder %s but found in %s on IMAP, move failed) " , existing_mail . id , existing_mail . folder_id , folder . imap_name )
continue
2026-07-17 22:50:02 +02:00
# Track folder moves: update folder_id and imap_uid if the
# mail now appears in a different IMAP folder.
if existing_mail . folder_id != folder . id :
existing_mail . folder_id = folder . id
existing_mail . imap_uid = uid_str
elif not existing_mail . imap_uid :
existing_mail . imap_uid = uid_str
2026-07-16 23:08:37 +02:00
# Save attachments for existing emails that have has_attachments but no records
if attachments and existing_mail . has_attachments :
existing_att_count = (
await db . execute (
2026-07-20 13:17:07 +02:00
select ( func . count ( ) ) . select_from ( MailAttachment ) . where (
2026-07-16 23:08:37 +02:00
and_ (
MailAttachment . mail_id == existing_mail . id ,
MailAttachment . tenant_id == tenant_id ,
)
)
)
) . scalar ( ) or 0
if existing_att_count == 0 :
for att_data in attachments :
try :
2026-07-16 23:23:27 +02:00
raw_filename = att_data [ " filename " ] or " attachment "
2026-07-16 23:30:13 +02:00
decoded_filename = _decode_mime_filename ( raw_filename )
2026-07-16 23:08:37 +02:00
storage_path = await _save_attachment_to_storage (
2026-07-16 23:23:27 +02:00
existing_mail . id , decoded_filename , att_data [ " content " ]
2026-07-16 23:08:37 +02:00
)
attachment = MailAttachment (
tenant_id = tenant_id ,
mail_id = existing_mail . id ,
2026-07-16 23:23:27 +02:00
filename = _sanitize_filename ( decoded_filename ) ,
2026-07-16 23:08:37 +02:00
mime_type = att_data [ " mime_type " ] ,
size_bytes = att_data [ " size " ] ,
storage_path = storage_path ,
content_id = att_data . get ( " content_id " ) ,
)
db . add ( attachment )
except Exception as att_err :
logger . warning ( f " Failed to save attachment for existing mail { existing_mail . id } : { att_err } " )
continue
await db . flush ( )
2026-07-15 14:36:45 +02:00
continue
mail = Mail (
tenant_id = tenant_id ,
account_id = account . id ,
folder_id = folder . id ,
2026-07-17 22:50:02 +02:00
imap_uid = uid_str ,
2026-07-15 14:36:45 +02:00
message_id = message_id ,
thread_id = thread_id ,
in_reply_to = in_reply_to ,
references_header = refs ,
subject = subject ,
from_address = from_addr ,
to_addresses = to_addrs ,
cc_addresses = cc_addrs ,
body_text = body_text ,
body_html = body_html ,
body_html_sanitized = sanitize_html ( body_html ) ,
has_attachments = len ( attachments ) > 0 ,
size_bytes = len ( raw_email ) ,
received_at = received_at ,
2026-07-01 15:41:27 +02:00
)
2026-07-15 14:36:45 +02:00
db . add ( mail )
2026-07-15 18:43:38 +02:00
await db . flush ( )
2026-07-15 14:36:45 +02:00
synced_count + = 1
2026-07-15 20:41:57 +02:00
# Collect for new-mail notifications
new_mails . append ( {
" from " : from_addr ,
" subject " : subject ,
} )
2026-07-15 18:43:38 +02:00
# Save attachments to storage and DB
for att_data in attachments :
try :
2026-07-16 23:23:27 +02:00
# Decode MIME-encoded filenames (e.g. =?utf-8?q?...?=)
raw_filename = att_data [ " filename " ] or " attachment "
2026-07-16 23:30:13 +02:00
decoded_filename = _decode_mime_filename ( raw_filename )
2026-07-15 18:43:38 +02:00
storage_path = await _save_attachment_to_storage (
2026-07-16 23:23:27 +02:00
mail . id , decoded_filename , att_data [ " content " ]
2026-07-15 18:43:38 +02:00
)
attachment = MailAttachment (
tenant_id = tenant_id ,
mail_id = mail . id ,
2026-07-16 23:23:27 +02:00
filename = _sanitize_filename ( decoded_filename ) ,
2026-07-15 18:43:38 +02:00
mime_type = att_data [ " mime_type " ] ,
size_bytes = att_data [ " size " ] ,
storage_path = storage_path ,
content_id = att_data . get ( " content_id " ) ,
)
db . add ( attachment )
2026-07-16 23:08:37 +02:00
except Exception as att_err :
logger . warning ( f " Failed to save attachment for new mail { mail . id } : { att_err } " )
2026-07-15 18:43:38 +02:00
continue
await db . flush ( )
2026-07-15 14:36:45 +02:00
# Update folder counts
total = (
await db . execute (
2026-07-20 13:17:07 +02:00
select ( func . count ( ) ) . select_from ( Mail ) . where (
2026-07-15 14:36:45 +02:00
and_ ( Mail . folder_id == folder . id , Mail . tenant_id == tenant_id )
)
2026-07-01 15:41:27 +02:00
)
2026-07-15 14:36:45 +02:00
) . scalar ( )
unread = (
await db . execute (
2026-07-20 13:17:07 +02:00
select ( func . count ( ) ) . select_from ( Mail ) . where (
2026-07-15 14:36:45 +02:00
and_ (
Mail . folder_id == folder . id ,
Mail . tenant_id == tenant_id ,
2026-07-20 13:17:07 +02:00
Mail . is_seen . is_ ( False ) ,
2026-07-15 14:36:45 +02:00
)
2026-07-01 15:41:27 +02:00
)
)
2026-07-15 14:36:45 +02:00
) . scalar ( )
folder . total_count = total or 0
folder . unread_count = unread or 0
2026-07-01 15:41:27 +02:00
2026-07-15 14:36:45 +02:00
await db . flush ( )
except Exception :
# Skip folders that can't be selected (e.g. no select permission)
continue
2026-07-15 20:41:57 +02:00
# ── New-mail notifications (max 10, then summary) ──
if new_mails :
try :
if len ( new_mails ) < = 10 :
for nm in new_mails :
try :
await create_notification (
db , account . tenant_id , account . user_id ,
" mail_new " ,
f " Neue E-Mail von { nm [ ' from ' ] } " ,
nm [ " subject " ] ,
)
except Exception :
pass
else :
try :
await create_notification (
db , account . tenant_id , account . user_id ,
" mail_new " ,
f " Neue E-Mails: { len ( new_mails ) } neue Nachrichten " ,
f " Account { account . email_address } hat { len ( new_mails ) } neue E-Mails empfangen. " ,
)
except Exception :
pass
await db . flush ( )
except Exception :
pass
2026-07-15 14:36:45 +02:00
await db . flush ( )
2026-07-01 15:41:27 +02:00
await client . logout ( )
return { " synced " : synced_count }
except Exception as e :
return { " synced " : 0 , " error " : str ( e ) }
def _compute_thread_id ( message_id : str , references : str , in_reply_to : str | None ) - > str :
""" Compute thread ID from References/In-Reply-To headers (F-MAIL-05). """
ref_parts : list [ str ] = [ ]
if references :
ref_parts = [ r . strip ( ) for r in references . split ( ) if r . strip ( ) ]
if in_reply_to and in_reply_to . strip ( ) not in ref_parts :
ref_parts . append ( in_reply_to . strip ( ) )
if ref_parts :
return ref_parts [ 0 ]
return message_id or str ( uuid . uuid4 ( ) )
# ─── SMTP Send Service (F-MAIL-02) ───
async def send_mail_via_smtp (
db : AsyncSession ,
* ,
tenant_id : uuid . UUID ,
user_id : uuid . UUID ,
account : MailAccount ,
to_addrs : list [ str ] ,
cc_addrs : list [ str ] = None ,
bcc_addrs : list [ str ] = None ,
subject : str = " " ,
body_html : str = " " ,
body_text : str = " " ,
in_reply_to : str | None = None ,
references_header : str | None = None ,
signature : MailSignature | None = None ,
2026-07-15 18:43:38 +02:00
attachment_paths : list [ dict ] | None = None ,
2026-07-01 15:41:27 +02:00
) - > dict :
2026-07-15 18:43:38 +02:00
""" Send an email via SMTP using aiosmtplib.
Args:
attachment_paths: list of dicts with keys ' path ' , ' filename ' , ' mime_type '
pointing to files on disk to attach.
"""
2026-07-01 15:41:27 +02:00
cc_addrs = cc_addrs or [ ]
bcc_addrs = bcc_addrs or [ ]
2026-07-15 18:43:38 +02:00
attachment_paths = attachment_paths or [ ]
2026-07-20 11:31:17 +02:00
logger = logging . getLogger ( __name__ )
2026-07-01 15:41:27 +02:00
# Apply signature if provided
if signature and signature . body_html :
body_html = body_html + f " <br><br> { signature . body_html } "
if body_text :
body_text = body_text + " \n \n -- \n " + _strip_html ( signature . body_html )
# Build email message
msg = EmailMessage ( )
msg [ " From " ] = formataddr ( ( account . display_name or " " , account . email_address ) )
msg [ " To " ] = " , " . join ( to_addrs )
if cc_addrs :
msg [ " Cc " ] = " , " . join ( cc_addrs )
msg [ " Subject " ] = subject
msg [ " Date " ] = formatdate ( localtime = True )
msg_id = make_msgid (
domain = account . email_address . split ( " @ " ) [ - 1 ] if " @ " in account . email_address else " localhost "
)
msg [ " Message-ID " ] = msg_id
if in_reply_to :
msg [ " In-Reply-To " ] = in_reply_to
if references_header :
msg [ " References " ] = references_header
if body_html :
msg . set_content ( body_text or _strip_html ( body_html ) , subtype = " plain " )
msg . add_alternative ( body_html , subtype = " html " )
else :
msg . set_content ( body_text , subtype = " plain " )
2026-07-15 18:43:38 +02:00
# Add attachments to the message
for att_info in attachment_paths :
file_path = att_info . get ( " path " , " " )
filename = att_info . get ( " filename " , os . path . basename ( file_path ) )
mime_type = att_info . get ( " mime_type " , " application/octet-stream " )
if not file_path or not os . path . exists ( file_path ) :
continue
with open ( file_path , " rb " ) as f : # noqa: ASYNC230
content = f . read ( )
# Determine maintype/subtype from mime_type
if " / " in mime_type :
maintype , subtype = mime_type . split ( " / " , 1 )
else :
maintype , subtype = " application " , " octet-stream "
msg . add_attachment (
content ,
maintype = maintype ,
subtype = subtype ,
filename = filename ,
)
2026-07-01 15:41:27 +02:00
# Send via SMTP
password = await get_account_password ( account )
try :
smtp = aiosmtplib . SMTP (
hostname = account . smtp_host ,
port = account . smtp_port ,
use_tls = account . smtp_tls ,
)
await smtp . connect ( )
await smtp . login ( account . username , password )
recipients = to_addrs + cc_addrs + bcc_addrs
await smtp . send_message ( msg , recipients = recipients )
await smtp . quit ( )
2026-07-17 23:17:52 +02:00
# Store sent mail in Sent folder — use configured mapping if set,
# otherwise flexible lookup to handle different IMAP naming conventions
if account . sent_folder_imap_name :
sent_folder = (
await db . execute (
select ( MailFolder ) . where (
and_ (
MailFolder . account_id == account . id ,
MailFolder . imap_name == account . sent_folder_imap_name ,
)
2026-07-01 15:41:27 +02:00
)
)
2026-07-17 23:17:52 +02:00
) . scalar_one_or_none ( )
else :
2026-07-17 22:50:02 +02:00
sent_folder = (
await db . execute (
select ( MailFolder ) . where (
and_ (
MailFolder . account_id == account . id ,
2026-07-17 23:17:52 +02:00
MailFolder . imap_name . in_ (
[ " Sent " , " INBOX.Sent " , " Sent Items " , " Sent Mail " ]
) ,
2026-07-17 22:50:02 +02:00
)
)
)
) . scalar_one_or_none ( )
2026-07-17 23:17:52 +02:00
if not sent_folder :
sent_folder = (
await db . execute (
select ( MailFolder ) . where (
and_ (
MailFolder . account_id == account . id ,
MailFolder . is_standard == True ,
MailFolder . imap_name . ilike ( " %s ent % " ) ,
)
)
)
) . scalar_one_or_none ( )
2026-07-01 15:41:27 +02:00
if sent_folder :
thread_id = _compute_thread_id ( msg_id , references_header or " " , in_reply_to )
sent_mail = Mail (
tenant_id = tenant_id ,
account_id = account . id ,
folder_id = sent_folder . id ,
message_id = msg_id ,
thread_id = thread_id ,
in_reply_to = in_reply_to ,
references_header = references_header ,
subject = subject ,
from_address = account . email_address ,
to_addresses = " , " . join ( to_addrs ) ,
cc_addresses = " , " . join ( cc_addrs ) ,
bcc_addresses = " , " . join ( bcc_addrs ) ,
body_text = body_text or _strip_html ( body_html ) ,
body_html = body_html ,
body_html_sanitized = sanitize_html ( body_html ) ,
is_seen = True ,
is_answered = bool ( in_reply_to ) ,
sent_at = datetime . now ( UTC ) ,
)
db . add ( sent_mail )
await db . flush ( )
2026-07-20 11:31:17 +02:00
# Upload sent mail to IMAP Sent folder via APPEND
try :
imap_client = aioimaplib . IMAP4_SSL ( host = account . imap_host , port = account . imap_port )
await imap_client . wait_hello_from_server ( )
await imap_client . login ( account . username , password )
# Build raw email bytes for APPEND
raw_email_bytes = msg . as_bytes ( )
append_resp = await imap_client . append (
sent_folder . imap_name ,
r ' ( \ Seen) ' ,
None ,
raw_email_bytes ,
)
if append_resp . result == ' OK ' :
logger . info ( " send_mail: uploaded sent mail to IMAP folder %s " , sent_folder . imap_name )
else :
logger . warning ( " send_mail: IMAP APPEND failed for sent folder %s : %s " , sent_folder . imap_name , append_resp )
await imap_client . logout ( )
except Exception as imap_exc :
logger . warning ( " send_mail: IMAP APPEND failed (non-critical): %s " , imap_exc )
2026-07-15 20:41:57 +02:00
# ── Notification: mail sent ──
try :
await create_notification (
db , tenant_id , user_id ,
" mail_sent " ,
" E-Mail gesendet " ,
subject ,
)
await db . flush ( )
except Exception :
pass
2026-07-01 15:41:27 +02:00
return { " status " : " sent " , " message_id " : msg_id }
except Exception as e :
2026-07-15 20:41:57 +02:00
# ── Notification: send error ──
try :
await create_notification (
db , tenant_id , user_id ,
" mail_send_error " ,
" E-Mail konnte nicht gesendet werden " ,
str ( e ) ,
)
await db . flush ( )
except Exception :
pass
2026-07-01 15:41:27 +02:00
return { " status " : " error " , " error " : str ( e ) }
async def reply_to_mail (
db : AsyncSession ,
* ,
tenant_id : uuid . UUID ,
user_id : uuid . UUID ,
original_mail : Mail ,
account : MailAccount ,
body_html : str ,
body_text : str = " " ,
reply_to_all : bool = False ,
signature : MailSignature | None = None ,
) - > dict :
""" Reply to a mail, setting In-Reply-To and References headers (F-MAIL-02). """
to_addrs = [ original_mail . from_address ]
if reply_to_all and original_mail . cc_addresses :
to_addrs . extend ( [ a . strip ( ) for a in original_mail . cc_addresses . split ( " , " ) if a . strip ( ) ] )
refs = original_mail . references_header or " "
new_refs = f " { refs } { original_mail . message_id } " . strip ( )
result = await send_mail_via_smtp (
db ,
tenant_id = tenant_id ,
user_id = user_id ,
account = account ,
to_addrs = to_addrs ,
subject = f " Re: { original_mail . subject } " . replace ( " Re: Re: " , " Re: " ) ,
body_html = body_html ,
body_text = body_text ,
in_reply_to = original_mail . message_id ,
references_header = new_refs ,
signature = signature ,
)
# Mark original as answered
original_mail . is_answered = True
await db . flush ( )
return result
async def forward_mail (
db : AsyncSession ,
* ,
tenant_id : uuid . UUID ,
user_id : uuid . UUID ,
original_mail : Mail ,
account : MailAccount ,
to_addrs : list [ str ] ,
cc_addrs : list [ str ] = None ,
body_html : str = " " ,
body_text : str = " " ,
signature : MailSignature | None = None ,
) - > dict :
""" Forward a mail with original as forwarded content (F-MAIL-02). """
fwd_subject = f " Fwd: { original_mail . subject } " . replace ( " Fwd: Fwd: " , " Fwd: " )
fwd_body = (
f " <br><br>----- Original Message -----<br> "
f " From: { original_mail . from_address } <br> "
f " Subject: { original_mail . subject } <br><br> "
f " { original_mail . body_html or original_mail . body_text } "
)
full_html = body_html + fwd_body
full_text = ( body_text or _strip_html ( body_html ) ) + " \n \n ----- Original Message ----- \n "
result = await send_mail_via_smtp (
db ,
tenant_id = tenant_id ,
user_id = user_id ,
account = account ,
to_addrs = to_addrs ,
cc_addrs = cc_addrs or [ ] ,
subject = fwd_subject ,
body_html = full_html ,
body_text = full_text ,
signature = signature ,
)
original_mail . is_forwarded = True
await db . flush ( )
return result
# ─── Template Service (F-MAIL-06) ───
def substitute_template_vars ( template_body : str , variables : dict [ str , str ] ) - > str :
""" Replace {{ placeholder}} variables in template body. """
result = template_body
for key , value in variables . items ( ) :
result = result . replace ( f " {{ {{ { key } }} }} " , value )
result = result . replace ( f " {{ {{ { key . lower ( ) } }} }} " , value )
result = result . replace ( f " {{ {{ { key . upper ( ) } }} }} " , value )
return result
# ─── Mail Rule Engine (F-MAIL-07) ───
def matches_condition ( mail : Mail , conditions : dict ) - > bool :
""" Check if a mail matches all rule conditions. """
for field , expected in conditions . items ( ) :
if field == " from_contains " :
if expected . lower ( ) not in mail . from_address . lower ( ) :
return False
elif field == " subject_contains " :
if expected . lower ( ) not in mail . subject . lower ( ) :
return False
elif field == " to_contains " :
if expected . lower ( ) not in mail . to_addresses . lower ( ) :
return False
elif field == " body_contains " :
body = ( mail . body_text + mail . body_html ) . lower ( )
if expected . lower ( ) not in body :
return False
elif field == " has_attachments " :
if mail . has_attachments != bool ( expected ) :
return False
elif field == " is_flagged " :
if mail . is_flagged != bool ( expected ) :
return False
return True
async def execute_rule_actions (
db : AsyncSession , mail : Mail , actions : dict , tenant_id : uuid . UUID
) - > dict :
""" Execute rule actions on a matching mail. """
results = { }
for action , value in actions . items ( ) :
if action == " move_to_folder " :
folder_id = uuid . UUID ( value ) if isinstance ( value , str ) else value
folder = (
await db . execute (
select ( MailFolder ) . where (
and_ ( MailFolder . id == folder_id , MailFolder . tenant_id == tenant_id )
)
)
) . scalar_one_or_none ( )
if folder :
mail . folder_id = folder . id
results [ " moved " ] = str ( folder . id )
elif action == " label " :
label_id = uuid . UUID ( value ) if isinstance ( value , str ) else value
existing = (
await db . execute (
select ( MailLabelAssignment ) . where (
and_ (
MailLabelAssignment . mail_id == mail . id ,
MailLabelAssignment . label_id == label_id ,
)
)
)
) . scalar_one_or_none ( )
if not existing :
assignment = MailLabelAssignment (
tenant_id = tenant_id ,
mail_id = mail . id ,
label_id = label_id ,
)
db . add ( assignment )
results [ " labeled " ] = str ( label_id )
elif action == " mark_seen " :
mail . is_seen = bool ( value )
results [ " seen " ] = bool ( value )
elif action == " mark_flagged " :
mail . is_flagged = bool ( value )
results [ " flagged " ] = bool ( value )
elif action == " forward_to " :
results [ " forward_to " ] = value
await db . flush ( )
return results
async def apply_rules_to_mail ( db : AsyncSession , mail : Mail , tenant_id : uuid . UUID ) - > list [ dict ] :
""" Find and apply all matching rules to a mail, sorted by priority. """
rules = (
(
await db . execute (
select ( MailRule )
. where (
and_ (
MailRule . tenant_id == tenant_id ,
MailRule . is_active ,
or_ (
MailRule . account_id == mail . account_id ,
MailRule . account_id . is_ ( None ) ,
) ,
)
)
. order_by ( MailRule . priority )
)
)
. scalars ( )
. all ( )
)
applied = [ ]
for rule in rules :
conditions = json . loads ( rule . conditions ) if rule . conditions else { }
actions = json . loads ( rule . actions ) if rule . actions else { }
if matches_condition ( mail , conditions ) :
result = await execute_rule_actions ( db , mail , actions , tenant_id )
applied . append ( { " rule_id " : str ( rule . id ) , " rule_name " : rule . name , " actions " : result } )
return applied
# ─── Vacation Auto-Reply (F-MAIL-08) ───
VACATION_DEDUP_HOURS = 24
async def should_send_vacation_reply (
db : AsyncSession ,
account_id : uuid . UUID ,
sender_address : str ,
tenant_id : uuid . UUID ,
) - > bool :
""" Check if vacation auto-reply should be sent (dedup within 24h). """
cutoff = datetime . now ( UTC ) - timedelta ( hours = VACATION_DEDUP_HOURS )
existing = (
await db . execute (
select ( VacationSentLog ) . where (
and_ (
VacationSentLog . account_id == account_id ,
VacationSentLog . sender_address == sender_address ,
VacationSentLog . sent_at > = cutoff ,
VacationSentLog . tenant_id == tenant_id ,
)
)
)
) . scalar_one_or_none ( )
return existing is None
async def log_vacation_sent (
db : AsyncSession ,
account_id : uuid . UUID ,
sender_address : str ,
tenant_id : uuid . UUID ,
) - > None :
""" Log that a vacation auto-reply was sent to a sender. """
log = VacationSentLog (
tenant_id = tenant_id ,
account_id = account_id ,
sender_address = sender_address ,
sent_at = datetime . now ( UTC ) ,
)
db . add ( log )
await db . flush ( )
# ─── PGP Service (F-MAIL-12) ───
def import_pgp_private_key ( private_key_armored : str , passphrase : str = " " ) - > tuple [ str , str ] :
""" Import a PGP private key. Returns (key_id, public_key_armored). """
key , _ = pgpy . PGPKey . from_blob ( private_key_armored )
if key . is_protected :
with key . unlock ( passphrase ) :
pub_key = key . pubkey
key_id = str ( key . fingerprint ) . upper ( ) [ - 16 : ]
return key_id , str ( pub_key )
pub_key = key . pubkey
key_id = str ( key . fingerprint ) . upper ( ) [ - 16 : ]
return key_id , str ( pub_key )
def import_pgp_public_key ( public_key_armored : str ) - > str :
""" Import a PGP public key. Returns key_id. """
key , _ = pgpy . PGPKey . from_blob ( public_key_armored )
return str ( key . fingerprint ) . upper ( ) [ - 16 : ]
def pgp_encrypt_message ( plaintext : str , recipient_public_key_armored : str ) - > str :
""" Encrypt a message with recipient ' s public PGP key. """
pub_key , _ = pgpy . PGPKey . from_blob ( recipient_public_key_armored )
msg = pgpy . PGPMessage . new ( plaintext )
encrypted = pub_key . encrypt ( msg )
return str ( encrypted )
def pgp_decrypt_message ( ciphertext : str , private_key_armored : str , passphrase : str = " " ) - > str :
""" Decrypt a PGP-encrypted message. """
key , _ = pgpy . PGPKey . from_blob ( private_key_armored )
enc_msg = pgpy . PGPMessage . from_blob ( ciphertext )
if key . is_protected :
with key . unlock ( passphrase ) :
decrypted = key . decrypt ( enc_msg )
return decrypted . message . decode ( " utf-8 " )
decrypted = key . decrypt ( enc_msg )
return decrypted . message . decode ( " utf-8 " )
# ─── Contact Linking (F-MAIL-10) ───
def extract_email_addresses ( text : str ) - > list [ str ] :
""" Extract email addresses from a text string. """
if not text :
return [ ]
return re . findall ( r " [a-zA-Z0-9._ % +-]+@[a-zA-Z0-9.-]+ \ .[a-zA-Z] { 2,} " , text )
# ─── Utility ───
def _strip_html ( html : str ) - > str :
""" Simple HTML to text conversion for plain text fallback. """
if not html :
return " "
# Remove tags
text = re . sub ( r " <[^>]+> " , " " , html )
# Replace HTML entities
text = (
text . replace ( " " , " " )
. replace ( " & " , " & " )
. replace ( " < " , " < " )
. replace ( " > " , " > " )
. replace ( " " " , ' " ' )
)
return text . strip ( )
def mail_to_response (
mail : Mail ,
attachments : list [ MailAttachment ] | None = None ,
labels : list [ MailLabel ] | None = None ,
) - > dict :
""" Convert a Mail ORM object to a response dict. """
resp = {
" id " : str ( mail . id ) ,
" account_id " : str ( mail . account_id ) ,
" folder_id " : str ( mail . folder_id ) ,
" message_id " : mail . message_id ,
" thread_id " : mail . thread_id ,
" in_reply_to " : mail . in_reply_to ,
" subject " : mail . subject ,
" from_address " : mail . from_address ,
2026-07-15 08:42:37 +02:00
" from_name " : mail . from_address . split ( " < " ) [ 0 ] . strip ( ) . strip ( ' " ' ) if " < " in mail . from_address else mail . from_address ,
" to_addresses " : extract_email_addresses ( mail . to_addresses ) if mail . to_addresses else [ ] ,
" cc_addresses " : extract_email_addresses ( mail . cc_addresses ) if mail . cc_addresses else [ ] ,
" bcc_addresses " : extract_email_addresses ( mail . bcc_addresses ) if mail . bcc_addresses else [ ] ,
2026-07-01 15:41:27 +02:00
" body_text " : mail . body_text ,
2026-07-15 08:42:37 +02:00
" body_html " : mail . body_html if mail . body_html else None ,
2026-07-01 15:41:27 +02:00
" body_html_sanitized " : mail . body_html_sanitized ,
2026-07-15 08:42:37 +02:00
" sanitized_html " : mail . body_html_sanitized ,
" date " : mail . received_at . isoformat ( ) if mail . received_at else ( mail . sent_at . isoformat ( ) if mail . sent_at else None ) ,
2026-07-01 15:41:27 +02:00
" is_seen " : mail . is_seen ,
" is_flagged " : mail . is_flagged ,
2026-07-20 10:44:52 +02:00
" flag_type " : getattr ( mail , ' flag_type ' , None ) ,
2026-07-01 15:41:27 +02:00
" is_draft " : mail . is_draft ,
" is_answered " : mail . is_answered ,
" is_forwarded " : mail . is_forwarded ,
" has_attachments " : mail . has_attachments ,
" size_bytes " : mail . size_bytes ,
" received_at " : mail . received_at ,
" sent_at " : mail . sent_at ,
" contact_id " : str ( mail . contact_id ) if mail . contact_id else None ,
" company_id " : str ( mail . company_id ) if mail . company_id else None ,
" attachments " : [ ] ,
" labels " : [ ] ,
}
if attachments :
2026-07-15 18:43:38 +02:00
resp [ " attachments " ] = [ attachment_to_response ( a ) for a in attachments ]
2026-07-01 15:41:27 +02:00
if labels :
resp [ " labels " ] = [
{ " id " : str ( lbl . id ) , " name " : lbl . name , " color " : lbl . color } for lbl in labels
]
return resp
def folder_to_response ( folder : MailFolder ) - > dict :
""" Convert MailFolder to response dict. """
return {
" id " : str ( folder . id ) ,
" account_id " : str ( folder . account_id ) ,
" name " : folder . name ,
" imap_name " : folder . imap_name ,
" parent_id " : str ( folder . parent_id ) if folder . parent_id else None ,
" is_standard " : folder . is_standard ,
" unread_count " : folder . unread_count ,
" total_count " : folder . total_count ,
}
def rule_to_response ( rule : MailRule ) - > dict :
""" Convert MailRule to response dict. """
return {
" id " : str ( rule . id ) ,
" name " : rule . name ,
" account_id " : str ( rule . account_id ) if rule . account_id else None ,
" priority " : rule . priority ,
" is_active " : rule . is_active ,
" conditions " : json . loads ( rule . conditions ) if rule . conditions else { } ,
" actions " : json . loads ( rule . actions ) if rule . actions else { } ,
}
def template_to_response ( template : MailTemplate ) - > dict :
""" Convert MailTemplate to response dict. """
return {
" id " : str ( template . id ) ,
" name " : template . name ,
" subject " : template . subject ,
" body_html " : template . body_html ,
}
def signature_to_response ( sig : MailSignature ) - > dict :
""" Convert MailSignature to response dict. """
return {
" id " : str ( sig . id ) ,
" name " : sig . name ,
" body_html " : sig . body_html ,
" account_id " : str ( sig . account_id ) if sig . account_id else None ,
" is_default " : sig . is_default ,
}
def label_to_response ( label : MailLabel ) - > dict :
""" Convert MailLabel to response dict. """
return {
" id " : str ( label . id ) ,
" name " : label . name ,
" color " : label . color ,
}
2026-07-15 19:26:37 +02:00
# ─── IMAP Flag Sync ───
async def imap_sync_mail_flags (
db : AsyncSession ,
mail_id : uuid . UUID ,
tenant_id : uuid . UUID ,
) - > None :
""" Sync is_seen/is_flagged flags from DB to IMAP server.
Connects to the IMAP server, selects the mail ' s folder,
and uses UID STORE to set/remove \\ Seen and \\ Flagged flags.
Non-critical: logs warnings on failure but does not raise.
"""
import logging
logger = logging . getLogger ( __name__ )
# Load the mail with its folder and account
mail = (
await db . execute (
select ( Mail ) . where (
and_ ( Mail . id == mail_id , Mail . tenant_id == tenant_id )
)
)
) . scalar_one_or_none ( )
if not mail :
logger . warning ( " imap_sync_mail_flags: mail %s not found " , mail_id )
return
folder = (
await db . execute (
select ( MailFolder ) . where (
and_ ( MailFolder . id == mail . folder_id , MailFolder . tenant_id == tenant_id )
)
)
) . scalar_one_or_none ( )
if not folder :
logger . warning ( " imap_sync_mail_flags: folder %s not found for mail %s " , mail . folder_id , mail_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_sync_mail_flags: account not found for mail %s " , mail_id )
return
if not mail . message_id :
logger . warning ( " imap_sync_mail_flags: mail %s has no message_id, cannot sync " , mail_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 )
# Select the folder
select_resp = await client . select ( folder . imap_name )
if select_resp . result != ' OK ' :
logger . warning ( " imap_sync_mail_flags: cannot select folder %s " , folder . imap_name )
return
# Find the UID by searching for the Message-ID header
search_resp = await client . uid_search ( f ' HEADER Message-ID " { mail . message_id } " ' )
uids_raw = search_resp [ 1 ] [ 0 ] if search_resp [ 1 ] and search_resp [ 1 ] [ 0 ] else b ' '
if isinstance ( uids_raw , ( bytes , bytearray ) ) :
uids = uids_raw . split ( )
else :
uids = [ ]
if not uids :
logger . warning ( " imap_sync_mail_flags: no UID found for Message-ID %s " , mail . message_id )
return
uid_str = uids [ 0 ] . decode ( ) if isinstance ( uids [ 0 ] , bytes ) else str ( uids [ 0 ] )
# Sync \Seen flag
if mail . is_seen :
await client . uid ( ' store ' , uid_str , ' +FLAGS ( \\ Seen) ' )
else :
await client . uid ( ' store ' , uid_str , ' -FLAGS ( \\ Seen) ' )
# Sync \Flagged flag
if mail . is_flagged :
await client . uid ( ' store ' , uid_str , ' +FLAGS ( \\ Flagged) ' )
else :
await client . uid ( ' store ' , uid_str , ' -FLAGS ( \\ Flagged) ' )
logger . info ( " imap_sync_mail_flags: synced flags for mail %s (UID %s ) " , mail_id , uid_str )
except Exception as exc :
logger . warning ( " imap_sync_mail_flags: failed for mail %s : %s " , mail_id , exc )
finally :
if client is not None :
try :
await client . logout ( )
except Exception :
pass
2026-07-15 19:44:41 +02:00
# ─── IMAP Delete ───
2026-07-20 13:17:07 +02:00
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
2026-07-15 19:44:41 +02:00
async def imap_delete_mail (
2026-07-20 13:17:07 +02:00
db : AsyncSession , mail_id : uuid . UUID , tenant_id : uuid . UUID , * , permanent : bool = False
2026-07-15 19:44:41 +02:00
) - > None :
""" Delete mail from IMAP server.
2026-07-20 13:17:07 +02:00
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.
2026-07-20 11:31:17 +02:00
Raises exceptions on failure so caller can queue for retry.
2026-07-15 19:44:41 +02:00
"""
import logging
logger = logging . getLogger ( __name__ )
mail = (
await db . execute (
select ( Mail ) . where (
and_ ( Mail . id == mail_id , Mail . tenant_id == tenant_id )
)
)
) . scalar_one_or_none ( )
if not mail :
logger . warning ( " imap_delete_mail: mail %s not found " , mail_id )
return
folder = (
await db . execute (
select ( MailFolder ) . where (
and_ ( MailFolder . id == mail . folder_id , MailFolder . tenant_id == tenant_id )
)
)
) . scalar_one_or_none ( )
if not folder :
logger . warning ( " imap_delete_mail: folder %s not found for mail %s " , mail . folder_id , mail_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_mail: account not found for mail %s " , mail_id )
return
2026-07-20 11:31:17 +02:00
if not mail . imap_uid and not mail . message_id :
logger . warning ( " imap_delete_mail: mail %s has no imap_uid or message_id, cannot sync " , mail_id )
2026-07-15 19:44:41 +02:00
return
2026-07-20 13:17:07 +02:00
# 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 )
2026-07-15 19:44:41 +02:00
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 )
select_resp = await client . select ( folder . imap_name )
if select_resp . result != ' OK ' :
2026-07-20 11:31:17 +02:00
raise RuntimeError ( f " cannot select folder { folder . imap_name } " )
2026-07-15 19:44:41 +02:00
2026-07-20 11:31:17 +02:00
# Use stored imap_uid directly; fall back to Message-ID search
if mail . imap_uid :
uid_str = mail . imap_uid
2026-07-15 19:44:41 +02:00
else :
2026-07-20 11:31:17 +02:00
search_resp = await client . uid_search ( f ' HEADER Message-ID " { mail . message_id } " ' )
uids_raw = search_resp [ 1 ] [ 0 ] if search_resp [ 1 ] and search_resp [ 1 ] [ 0 ] else b ' '
if isinstance ( uids_raw , ( bytes , bytearray ) ) :
uids = uids_raw . split ( )
else :
uids = [ ]
if not uids :
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 ] )
2026-07-15 19:44:41 +02:00
2026-07-20 13:17:07 +02:00
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 } " )
2026-07-15 19:44:41 +02:00
await client . uid ( ' store ' , uid_str , r ' +FLAGS ( \\ Deleted) ' )
await client . expunge ( )
2026-07-20 13:17:07 +02:00
logger . info ( " imap_delete_mail: moved mail %s (UID %s ) to Trash via COPY+DELETE " , mail_id , uid_str )
2026-07-15 19:44:41 +02:00
except Exception as exc :
logger . warning ( " imap_delete_mail: failed for mail %s : %s " , mail_id , exc )
2026-07-20 11:31:17 +02:00
raise
2026-07-15 19:44:41 +02:00
finally :
if client is not None :
try :
await client . logout ( )
except Exception :
pass
# ─── IMAP Move ───
async def imap_move_mail (
db : AsyncSession ,
mail_id : uuid . UUID ,
target_folder_id : uuid . UUID ,
tenant_id : uuid . UUID ,
) - > None :
""" Move mail to another folder on IMAP server.
2026-07-20 11:31:17 +02:00
Uses stored imap_uid directly; falls back to Message-ID search if missing.
2026-07-15 19:44:41 +02:00
Uses UID MOVE if supported, otherwise COPY + STORE \\ Deleted + EXPUNGE.
2026-07-20 11:31:17 +02:00
Raises exceptions on failure so caller can queue for retry.
2026-07-15 19:44:41 +02:00
"""
import logging
logger = logging . getLogger ( __name__ )
mail = (
await db . execute (
select ( Mail ) . where (
and_ ( Mail . id == mail_id , Mail . tenant_id == tenant_id )
)
)
) . scalar_one_or_none ( )
if not mail :
logger . warning ( " imap_move_mail: mail %s not found " , mail_id )
return
source_folder = (
await db . execute (
select ( MailFolder ) . where (
and_ ( MailFolder . id == mail . folder_id , MailFolder . tenant_id == tenant_id )
)
)
) . scalar_one_or_none ( )
if not source_folder :
logger . warning ( " imap_move_mail: source folder not found for mail %s " , mail_id )
return
target_folder = (
await db . execute (
select ( MailFolder ) . where (
and_ ( MailFolder . id == target_folder_id , MailFolder . tenant_id == tenant_id )
)
)
) . scalar_one_or_none ( )
if not target_folder :
logger . warning ( " imap_move_mail: target folder %s not found " , target_folder_id )
return
account = (
await db . execute (
select ( MailAccount ) . where (
and_ ( MailAccount . id == source_folder . account_id , MailAccount . tenant_id == tenant_id )
)
)
) . scalar_one_or_none ( )
if not account :
logger . warning ( " imap_move_mail: account not found for mail %s " , mail_id )
return
2026-07-20 11:31:17 +02:00
if not mail . imap_uid and not mail . message_id :
logger . warning ( " imap_move_mail: mail %s has no imap_uid or message_id, cannot sync " , mail_id )
2026-07-15 19:44:41 +02:00
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 )
select_resp = await client . select ( source_folder . imap_name )
if select_resp . result != ' OK ' :
2026-07-20 11:31:17 +02:00
raise RuntimeError ( f " cannot select folder { source_folder . imap_name } " )
2026-07-15 19:44:41 +02:00
2026-07-20 11:31:17 +02:00
# Use stored imap_uid directly; fall back to Message-ID search
if mail . imap_uid :
uid_str = mail . imap_uid
2026-07-15 19:44:41 +02:00
else :
2026-07-20 11:31:17 +02:00
search_resp = await client . uid_search ( f ' HEADER Message-ID " { mail . message_id } " ' )
uids_raw = search_resp [ 1 ] [ 0 ] if search_resp [ 1 ] and search_resp [ 1 ] [ 0 ] else b ' '
if isinstance ( uids_raw , ( bytes , bytearray ) ) :
uids = uids_raw . split ( )
else :
uids = [ ]
if not uids :
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 ] )
2026-07-15 19:44:41 +02:00
# Try UID MOVE first; fall back to COPY + STORE \\Deleted + EXPUNGE
try :
move_resp = await client . uid ( ' move ' , uid_str , target_folder . imap_name )
if move_resp . result == ' OK ' :
logger . info ( " imap_move_mail: moved mail %s (UID %s ) via UID MOVE " , mail_id , uid_str )
return
except Exception as move_exc :
logger . info ( " imap_move_mail: UID MOVE not supported, falling back: %s " , move_exc )
# Fallback: COPY + STORE \\Deleted + EXPUNGE
copy_resp = await client . uid ( ' copy ' , uid_str , target_folder . imap_name )
if copy_resp . result != ' OK ' :
2026-07-20 11:31:17 +02:00
raise RuntimeError ( f " COPY failed for mail { mail_id } " )
2026-07-15 19:44:41 +02:00
await client . uid ( ' store ' , uid_str , r ' +FLAGS ( \\ Deleted) ' )
await client . expunge ( )
logger . info ( " imap_move_mail: moved mail %s (UID %s ) via COPY+DELETE " , mail_id , uid_str )
except Exception as exc :
logger . warning ( " imap_move_mail: failed for mail %s : %s " , mail_id , exc )
2026-07-20 11:31:17 +02:00
raise
2026-07-15 19:44:41 +02:00
finally :
if client is not None :
try :
await client . logout ( )
except Exception :
pass
# ─── Draft Save / Update ───
async def save_draft (
db : AsyncSession ,
account_id : uuid . UUID ,
tenant_id : uuid . UUID ,
user_id : uuid . UUID ,
data : dict ,
) - > Mail :
""" Save a new draft mail to DB and IMAP Drafts folder. """
import logging
logger = logging . getLogger ( __name__ )
# 1. Find the Drafts folder (imap_name contains 'Drafts')
account = (
await db . execute (
select ( MailAccount ) . where (
and_ ( MailAccount . id == account_id , MailAccount . tenant_id == tenant_id )
)
)
) . scalar_one_or_none ( )
if not account :
raise ValueError ( " Account not found " )
folders = (
await db . execute (
select ( MailFolder ) . where (
and_ ( MailFolder . account_id == account_id , MailFolder . tenant_id == tenant_id )
)
)
) . scalars ( ) . all ( )
drafts_folder = None
for f in folders :
if ' draft ' in f . imap_name . lower ( ) :
drafts_folder = f
break
if not drafts_folder :
raise ValueError ( " No Drafts folder found for this account " )
# 2. Create Mail record
to_str = ' , ' . join ( data . get ( ' to ' , [ ] ) )
cc_str = ' , ' . join ( data . get ( ' cc ' , [ ] ) )
bcc_str = ' , ' . join ( data . get ( ' bcc ' , [ ] ) )
subject = data . get ( ' subject ' , ' ' )
body_text = data . get ( ' body_text ' , ' ' )
body_html = data . get ( ' body_html ' , ' ' )
msg_id = make_msgid ( )
now = datetime . now ( UTC )
mail = Mail (
tenant_id = tenant_id ,
account_id = account_id ,
folder_id = drafts_folder . id ,
message_id = msg_id ,
thread_id = msg_id ,
subject = subject ,
from_address = account . email_address ,
to_addresses = to_str ,
cc_addresses = cc_str ,
bcc_addresses = bcc_str ,
body_text = body_text ,
body_html = body_html ,
body_html_sanitized = body_html ,
is_seen = True ,
is_flagged = False ,
is_draft = True ,
is_answered = False ,
is_forwarded = False ,
has_attachments = False ,
size_bytes = len ( body_text . encode ( ' utf-8 ' ) ) ,
received_at = now ,
sent_at = None ,
)
db . add ( mail )
await db . flush ( )
2026-07-15 20:41:57 +02:00
# ── Notification: draft saved ──
try :
await create_notification (
db , tenant_id , user_id ,
" mail_draft " ,
" Entwurf gespeichert " ,
subject or " Ohne Betreff " ,
)
await db . flush ( )
except Exception :
pass
2026-07-15 19:44:41 +02:00
# 3. Build RFC822 message and APPEND to IMAP Drafts folder
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 )
# Build RFC822 message
email_msg = EmailMessage ( )
email_msg [ ' From ' ] = formataddr ( ( account . display_name , account . email_address ) )
if to_str :
email_msg [ ' To ' ] = to_str
if cc_str :
email_msg [ ' Cc ' ] = cc_str
email_msg [ ' Subject ' ] = subject
email_msg [ ' Date ' ] = formatdate ( localtime = True )
email_msg [ ' Message-ID ' ] = msg_id
email_msg . set_content ( body_text if body_text else ' ' )
if body_html :
email_msg . add_alternative ( body_html , subtype = ' html ' )
rfc822_bytes = email_msg . as_bytes ( )
# APPEND to Drafts folder
append_resp = await client . append (
drafts_folder . imap_name ,
r ' ( \\ Draft) ' ,
str ( int ( now . timestamp ( ) ) ) ,
rfc822_bytes ,
)
if append_resp . result != ' OK ' :
logger . warning ( " save_draft: IMAP APPEND failed for drafts folder %s " , drafts_folder . imap_name )
else :
logger . info ( " save_draft: appended draft %s to IMAP Drafts " , mail . id )
except Exception as exc :
logger . warning ( " save_draft: IMAP append failed (non-critical): %s " , exc )
finally :
if client is not None :
try :
await client . logout ( )
except Exception :
pass
return mail
async def update_draft (
db : AsyncSession ,
mail_id : uuid . UUID ,
tenant_id : uuid . UUID ,
data : dict ,
) - > Mail :
""" Update an existing draft. """
import logging
logger = logging . getLogger ( __name__ )
mail = (
await db . execute (
select ( Mail ) . where (
and_ ( Mail . id == mail_id , Mail . tenant_id == tenant_id )
)
)
) . scalar_one_or_none ( )
if not mail :
raise ValueError ( " Mail not found " )
if not mail . is_draft :
raise ValueError ( " Mail is not a draft " )
# 2. Update fields
to_str = ' , ' . join ( data . get ( ' to ' , [ ] ) )
cc_str = ' , ' . join ( data . get ( ' cc ' , [ ] ) )
bcc_str = ' , ' . join ( data . get ( ' bcc ' , [ ] ) )
mail . to_addresses = to_str
mail . cc_addresses = cc_str
mail . bcc_addresses = bcc_str
mail . subject = data . get ( ' subject ' , ' ' )
mail . body_text = data . get ( ' body_text ' , ' ' )
mail . body_html = data . get ( ' body_html ' , ' ' )
mail . body_html_sanitized = data . get ( ' body_html ' , ' ' )
mail . size_bytes = len ( mail . body_text . encode ( ' utf-8 ' ) )
await db . flush ( )
# 3. Delete old IMAP copy and APPEND new one
folder = (
await db . execute (
select ( MailFolder ) . where (
and_ ( MailFolder . id == mail . folder_id , MailFolder . tenant_id == tenant_id )
)
)
) . scalar_one_or_none ( )
if not folder :
return mail
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 :
return mail
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 )
# Delete old copy from IMAP
select_resp = await client . select ( folder . imap_name )
if select_resp . result == ' OK ' and mail . message_id :
search_resp = await client . uid_search ( f ' HEADER Message-ID " { mail . message_id } " ' )
uids_raw = search_resp [ 1 ] [ 0 ] if search_resp [ 1 ] and search_resp [ 1 ] [ 0 ] else b ' '
if isinstance ( uids_raw , ( bytes , bytearray ) ) :
uids = uids_raw . split ( )
else :
uids = [ ]
if uids :
uid_str = uids [ 0 ] . decode ( ) if isinstance ( uids [ 0 ] , bytes ) else str ( uids [ 0 ] )
await client . uid ( ' store ' , uid_str , r ' +FLAGS ( \\ Deleted) ' )
await client . expunge ( )
# APPEND new copy
email_msg = EmailMessage ( )
email_msg [ ' From ' ] = formataddr ( ( account . display_name , account . email_address ) )
if to_str :
email_msg [ ' To ' ] = to_str
if cc_str :
email_msg [ ' Cc ' ] = cc_str
email_msg [ ' Subject ' ] = mail . subject
email_msg [ ' Date ' ] = formatdate ( localtime = True )
email_msg [ ' Message-ID ' ] = mail . message_id
email_msg . set_content ( mail . body_text if mail . body_text else ' ' )
if mail . body_html :
email_msg . add_alternative ( mail . body_html , subtype = ' html ' )
rfc822_bytes = email_msg . as_bytes ( )
now = datetime . now ( UTC )
append_resp = await client . append (
folder . imap_name ,
r ' ( \\ Draft) ' ,
str ( int ( now . timestamp ( ) ) ) ,
rfc822_bytes ,
)
if append_resp . result != ' OK ' :
logger . warning ( " update_draft: IMAP APPEND failed for drafts folder %s " , folder . imap_name )
else :
logger . info ( " update_draft: appended updated draft %s to IMAP Drafts " , mail . id )
except Exception as exc :
logger . warning ( " update_draft: IMAP sync failed (non-critical): %s " , exc )
finally :
if client is not None :
try :
await client . logout ( )
except Exception :
pass
return mail
2026-07-15 20:28:11 +02:00
# ─── 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 )
2026-07-15 20:41:57 +02:00
try :
await create_notification (
db , account . tenant_id , account . user_id ,
" mail_folder " ,
" Ordner erstellt " ,
folder_name ,
)
await db . flush ( )
except Exception :
pass
2026-07-15 20:28:11 +02:00
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 )
2026-07-15 20:41:57 +02:00
try :
await create_notification (
db , account . tenant_id , account . user_id ,
" mail_folder " ,
" Ordner gelöscht " ,
folder . imap_name ,
)
await db . flush ( )
except Exception :
pass
2026-07-15 20:28:11 +02:00
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 ,
)
2026-07-15 20:41:57 +02:00
try :
await create_notification (
db , account . tenant_id , account . user_id ,
" mail_sync_error " ,
" Synchronisierung fehlgeschlagen " ,
f " Account { account . email_address } : { exc } " ,
)
except Exception :
pass
2026-07-15 20:28:11 +02:00
# commit per-account so partial progress is saved
try :
await db . commit ( )
except Exception :
await db . rollback ( )
2026-07-20 11:31:17 +02:00
async def process_sync_queue ( db : AsyncSession ) - > None :
""" Process pending IMAP operations from the sync queue.
Called at the start of each auto-sync loop iteration.
Retries failed delete/move operations.
"""
import logging
logger = logging . getLogger ( __name__ )
from app . plugins . builtins . mail . models import MailSyncQueue
pending = (
await db . execute (
select ( MailSyncQueue ) . where (
and_ (
MailSyncQueue . status == " pending " ,
MailSyncQueue . attempts < MailSyncQueue . max_attempts ,
)
)
)
) . scalars ( ) . all ( )
if not pending :
return
logger . info ( " process_sync_queue: processing %d pending operation(s) " , len ( pending ) )
for entry in pending :
try :
if entry . operation == " delete " :
2026-07-20 13:17:07 +02:00
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 )
2026-07-20 11:31:17 +02:00
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 )
else :
logger . warning ( " process_sync_queue: unknown operation %s " , entry . operation )
entry . status = " failed "
entry . last_error = f " Unknown operation: { entry . operation } "
continue
entry . status = " completed "
entry . updated_at = datetime . now ( UTC )
logger . info ( " process_sync_queue: completed %s for mail %s " , entry . operation , entry . mail_id )
except Exception as exc :
entry . attempts + = 1
entry . last_error = str ( exc )
entry . updated_at = datetime . now ( UTC )
if entry . attempts > = entry . max_attempts :
entry . status = " failed "
logger . warning (
" process_sync_queue: giving up on %s for mail %s after %d attempts: %s " ,
entry . operation , entry . mail_id , entry . attempts , exc ,
)
else :
logger . info (
" process_sync_queue: retry %d / %d for %s mail %s : %s " ,
entry . attempts , entry . max_attempts , entry . operation , entry . mail_id , exc ,
)
await db . flush ( )