1149 lines
49 KiB
Python
1149 lines
49 KiB
Python
|
|
"""IMAP sync service for the Mail plugin (F-MAIL-01).
|
|||
|
|
|
|||
|
|
Extracted from services.py as part of the God-object split (BUG-018 pilot).
|
|||
|
|
Re-exported by ``app.plugins.builtins.mail.services``.
|
|||
|
|
|
|||
|
|
Contains folder sync, account sync, thread-id computation and the German
|
|||
|
|
folder name mapping for standard IMAP folders.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import logging
|
|||
|
|
import re
|
|||
|
|
import uuid
|
|||
|
|
from datetime import UTC, datetime
|
|||
|
|
from email import message_from_bytes
|
|||
|
|
|
|||
|
|
import aioimaplib
|
|||
|
|
from sqlalchemy import and_, func, select
|
|||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
|
|
|||
|
|
from app.core.notifications import create_notification
|
|||
|
|
from app.plugins.builtins.mail.attachments import (
|
|||
|
|
_decode_mime_filename,
|
|||
|
|
_sanitize_filename,
|
|||
|
|
_save_attachment_to_storage,
|
|||
|
|
)
|
|||
|
|
from app.plugins.builtins.mail.models import (
|
|||
|
|
Mail,
|
|||
|
|
MailAccount,
|
|||
|
|
MailAttachment,
|
|||
|
|
MailFolder,
|
|||
|
|
)
|
|||
|
|
from app.plugins.builtins.mail.sanitize import sanitize_html
|
|||
|
|
|
|||
|
|
logger = logging.getLogger(__name__)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# German display names for standard IMAP folders.
|
|||
|
|
# Keys are full IMAP paths (with dot delimiter) and also bare leaf names.
|
|||
|
|
IMAP_FOLDER_NAME_MAP = {
|
|||
|
|
"INBOX": "Posteingang",
|
|||
|
|
"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)
|
|||
|
|
"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",
|
|||
|
|
"Archive": "Archiv",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Standard IMAP folders that are always considered "standard"
|
|||
|
|
STANDARD_IMAP_FOLDERS = {
|
|||
|
|
"INBOX",
|
|||
|
|
"INBOX.Sent", "INBOX.Drafts", "INBOX.Trash", "INBOX.Archive",
|
|||
|
|
"INBOX.spam", "INBOX.Spam",
|
|||
|
|
"Sent", "Sent Items", "Sent Mail",
|
|||
|
|
"Drafts", "Draft",
|
|||
|
|
"Spam", "Junk", "Junk Email", "Junk E-mail",
|
|||
|
|
"Trash", "Deleted", "Deleted Items",
|
|||
|
|
"Archive",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
MAX_EMAILS_PER_FOLDER = 2000
|
|||
|
|
|
|||
|
|
|
|||
|
|
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]
|
|||
|
|
# 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]
|
|||
|
|
return imap_name
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _parse_imap_list_response(response) -> tuple[list[tuple[str, str]], str]:
|
|||
|
|
"""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"
|
|||
|
|
|
|||
|
|
Returns (folders, delimiter) where delimiter is the hierarchy separator
|
|||
|
|
detected from the LIST response (defaults to '.' if not found).
|
|||
|
|
"""
|
|||
|
|
folders: list[tuple[str, str]] = []
|
|||
|
|
delimiter = '.'
|
|||
|
|
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
|
|||
|
|
# Extract quoted segments — the delimiter is the first quoted string,
|
|||
|
|
# the folder name is the second.
|
|||
|
|
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))
|
|||
|
|
return folders, delimiter
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _build_folder_hierarchy(
|
|||
|
|
imap_folders: list[tuple[str, str]],
|
|||
|
|
account_id: uuid.UUID,
|
|||
|
|
tenant_id: uuid.UUID,
|
|||
|
|
existing_folders: dict[str, MailFolder],
|
|||
|
|
delimiter: str = '.',
|
|||
|
|
folder_mapping: dict[str, str | None] | None = None,
|
|||
|
|
) -> list[MailFolder]:
|
|||
|
|
"""Create or update MailFolder records from IMAP LIST response.
|
|||
|
|
|
|||
|
|
Handles delimiter-separated hierarchies (e.g. INBOX.Sent → parent=INBOX).
|
|||
|
|
Updates existing folders in-place (name, is_standard, parent_id) so
|
|||
|
|
that stale DB records with wrong imap_name values get corrected.
|
|||
|
|
"""
|
|||
|
|
result: list[MailFolder] = []
|
|||
|
|
|
|||
|
|
# 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
|
|||
|
|
|
|||
|
|
# First pass: create or update folder records
|
|||
|
|
for _flags, imap_name in imap_folders:
|
|||
|
|
if not imap_name:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
display_name = _get_german_folder_name(imap_name)
|
|||
|
|
is_standard = imap_name in STANDARD_IMAP_FOLDERS
|
|||
|
|
|
|||
|
|
# 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"
|
|||
|
|
|
|||
|
|
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)
|
|||
|
|
|
|||
|
|
# 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]
|
|||
|
|
folder.parent_id = parent.id # may be None for new folders; fixed after flush
|
|||
|
|
else:
|
|||
|
|
folder.parent_id = None
|
|||
|
|
else:
|
|||
|
|
folder.parent_id = None
|
|||
|
|
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
|
|||
|
|
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:
|
|||
|
|
logger.debug("Ignored exception in mail service", exc_info=True)
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def get_account_password(account: MailAccount) -> str:
|
|||
|
|
"""Decrypt and return the account password (internal use only).
|
|||
|
|
|
|||
|
|
Uses per-account salt if available, falls back to legacy salt for old accounts.
|
|||
|
|
"""
|
|||
|
|
from app.plugins.builtins.mail.crypto import decrypt_password
|
|||
|
|
|
|||
|
|
return decrypt_password(account.encrypted_password, account.password_salt or None)
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def imap_sync_folder(
|
|||
|
|
db: AsyncSession,
|
|||
|
|
folder_id: uuid.UUID,
|
|||
|
|
tenant_id: uuid.UUID,
|
|||
|
|
) -> dict:
|
|||
|
|
"""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).
|
|||
|
|
"""
|
|||
|
|
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"}
|
|||
|
|
|
|||
|
|
# 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()
|
|||
|
|
|
|||
|
|
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}"}
|
|||
|
|
|
|||
|
|
# 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')
|
|||
|
|
|
|||
|
|
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 = []
|
|||
|
|
|
|||
|
|
# First sync: limit to last 50
|
|||
|
|
if not max_uid_result and len(uids) > MAX_EMAILS_PER_FOLDER:
|
|||
|
|
uids = uids[-MAX_EMAILS_PER_FOLDER:]
|
|||
|
|
|
|||
|
|
synced_count = 0
|
|||
|
|
for uid in uids:
|
|||
|
|
uid_str = uid.decode() if isinstance(uid, bytes) else str(uid)
|
|||
|
|
|
|||
|
|
# Skip if we already have this UID (can happen with UID * search)
|
|||
|
|
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()
|
|||
|
|
|
|||
|
|
# Also check by message_id — but ONLY within the same folder
|
|||
|
|
# to prevent cross-folder moves. A mail in Trash and INBOX with
|
|||
|
|
# the same message_id are separate copies (like real mail clients).
|
|||
|
|
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.folder_id == folder.id,
|
|||
|
|
Mail.tenant_id == tenant_id,
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
).scalar_one_or_none()
|
|||
|
|
|
|||
|
|
if existing_mail:
|
|||
|
|
# 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
|
|||
|
|
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:
|
|||
|
|
logger.debug("Ignored exception in mail service", exc_info=True)
|
|||
|
|
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
# Publish mail.received event
|
|||
|
|
from app.core.event_bus import get_event_bus
|
|||
|
|
event_bus = get_event_bus()
|
|||
|
|
await event_bus.publish('mail.received', {
|
|||
|
|
'mail_id': str(mail.id),
|
|||
|
|
'tenant_id': str(tenant_id),
|
|||
|
|
'account_id': str(account.id),
|
|||
|
|
'folder_id': str(folder.id),
|
|||
|
|
'subject': subject,
|
|||
|
|
'from_address': from_addr,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
# Lifecycle hook: mail.after_receive
|
|||
|
|
from app.core.hooks import do_action
|
|||
|
|
await do_action("mail.after_receive", {'mail_id': str(mail.id), 'tenant_id': str(tenant_id), 'account_id': str(account.id), 'folder_id': str(folder.id), 'subject': subject, 'from_address': from_addr}, db=db, tenant_id=tenant_id)
|
|||
|
|
await do_action("mail.after_create", {'mail_id': str(mail.id), 'tenant_id': str(tenant_id), 'subject': subject, 'from_address': from_addr}, db=db, tenant_id=tenant_id)
|
|||
|
|
|
|||
|
|
# Outbox event: mail.received
|
|||
|
|
from app.core.outbox import enqueue_outbox_event
|
|||
|
|
await enqueue_outbox_event(db, tenant_id, 'mail.received', {'mail_id': str(mail.id), 'tenant_id': str(tenant_id), 'account_id': str(account.id), 'folder_id': str(folder.id), 'subject': subject, 'from_address': from_addr}, aggregate_type='mail', aggregate_id=mail.id)
|
|||
|
|
|
|||
|
|
# 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
|
|||
|
|
|
|||
|
|
# Vanished-UID check: delete DB mails whose UID no longer exists on IMAP server
|
|||
|
|
try:
|
|||
|
|
vanished_search = await client.uid_search('ALL')
|
|||
|
|
vanished_raw = vanished_search[1][0] if vanished_search[1] and vanished_search[1][0] else b''
|
|||
|
|
if isinstance(vanished_raw, (bytes, bytearray)):
|
|||
|
|
imap_uids_set = {u.decode() if isinstance(u, bytes) else str(u) for u in vanished_raw.split()}
|
|||
|
|
else:
|
|||
|
|
imap_uids_set = set()
|
|||
|
|
db_mails = (
|
|||
|
|
await db.execute(
|
|||
|
|
select(Mail).where(
|
|||
|
|
and_(
|
|||
|
|
Mail.folder_id == folder_id,
|
|||
|
|
Mail.tenant_id == tenant_id,
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
).scalars().all()
|
|||
|
|
for db_mail in db_mails:
|
|||
|
|
if db_mail.imap_uid:
|
|||
|
|
if db_mail.imap_uid not in imap_uids_set:
|
|||
|
|
logger.info("imap_sync_folder: deleting vanished mail %s (UID %s no longer on server)", db_mail.id, db_mail.imap_uid)
|
|||
|
|
await db.delete(db_mail)
|
|||
|
|
elif db_mail.message_id:
|
|||
|
|
# Mail has no UID — try to find it on IMAP by Message-ID
|
|||
|
|
try:
|
|||
|
|
mid_search = await client.uid_search(f'HEADER Message-ID "{db_mail.message_id}"')
|
|||
|
|
mid_raw = mid_search[1][0] if mid_search[1] and mid_search[1][0] else b''
|
|||
|
|
if isinstance(mid_raw, (bytes, bytearray)) and mid_raw:
|
|||
|
|
found_uids = mid_raw.decode().split()
|
|||
|
|
if found_uids:
|
|||
|
|
db_mail.imap_uid = found_uids[0]
|
|||
|
|
logger.info("imap_sync_folder: found UID %s for mail %s via Message-ID", found_uids[0], db_mail.id)
|
|||
|
|
else:
|
|||
|
|
logger.info("imap_sync_folder: deleting mail %s (no UID, not found on server by Message-ID)", db_mail.id)
|
|||
|
|
await db.delete(db_mail)
|
|||
|
|
else:
|
|||
|
|
logger.info("imap_sync_folder: deleting mail %s (no UID, not found on server by Message-ID)", db_mail.id)
|
|||
|
|
await db.delete(db_mail)
|
|||
|
|
except Exception as mid_exc:
|
|||
|
|
logger.warning("imap_sync_folder: Message-ID search failed for mail %s: %s", db_mail.id, mid_exc)
|
|||
|
|
await db.flush()
|
|||
|
|
except Exception as vanished_exc:
|
|||
|
|
logger.warning("imap_sync_folder: vanished-UID check failed for folder %s: %s", folder_id, vanished_exc)
|
|||
|
|
|
|||
|
|
# 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,
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
).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.is_seen.is_(False),
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
).scalar() or 0
|
|||
|
|
folder.total_count = total
|
|||
|
|
folder.unread_count = unread
|
|||
|
|
|
|||
|
|
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:
|
|||
|
|
logger.debug("Ignored exception in mail service", exc_info=True)
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def imap_sync_account(
|
|||
|
|
db: AsyncSession,
|
|||
|
|
account_id: uuid.UUID,
|
|||
|
|
tenant_id: uuid.UUID,
|
|||
|
|
) -> dict:
|
|||
|
|
"""Sync mail folders and messages from IMAP server.
|
|||
|
|
|
|||
|
|
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.
|
|||
|
|
"""
|
|||
|
|
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"}
|
|||
|
|
|
|||
|
|
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:
|
|||
|
|
logger.debug("Ignored exception in mail service", exc_info=True)
|
|||
|
|
return {"synced": 0, "error": "Account is not active"}
|
|||
|
|
|
|||
|
|
password = await get_account_password(account)
|
|||
|
|
|
|||
|
|
# ── IMAP connection ──
|
|||
|
|
try:
|
|||
|
|
client = aioimaplib.IMAP4_SSL(host=account.imap_host, port=account.imap_port)
|
|||
|
|
await client.wait_hello_from_server()
|
|||
|
|
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:
|
|||
|
|
logger.debug("Ignored exception in mail service", exc_info=True)
|
|||
|
|
return {"synced": 0, "error": f"IMAP connection failed: {e}"}
|
|||
|
|
|
|||
|
|
# ── IMAP login ──
|
|||
|
|
try:
|
|||
|
|
await client.login(account.username, password)
|
|||
|
|
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:
|
|||
|
|
logger.debug("Ignored exception in mail service", exc_info=True)
|
|||
|
|
try:
|
|||
|
|
await client.logout()
|
|||
|
|
except Exception:
|
|||
|
|
logger.debug("Ignored exception in mail service", exc_info=True)
|
|||
|
|
return {"synced": 0, "error": f"IMAP login failed: {e}"}
|
|||
|
|
|
|||
|
|
# ── 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:
|
|||
|
|
logger.debug("Ignored exception in mail service", exc_info=True)
|
|||
|
|
except Exception:
|
|||
|
|
logger.debug("Ignored exception in mail service", exc_info=True)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# 1) LIST all folders from IMAP server
|
|||
|
|
list_response = await client.list('""', '"*"')
|
|||
|
|
imap_folders, imap_delimiter = _parse_imap_list_response(list_response)
|
|||
|
|
|
|||
|
|
# If LIST returned nothing, fall back to standard folders (dot-delimited)
|
|||
|
|
if not imap_folders:
|
|||
|
|
imap_folders = [
|
|||
|
|
('', 'INBOX'),
|
|||
|
|
('', 'INBOX.Sent'),
|
|||
|
|
('', 'INBOX.Drafts'),
|
|||
|
|
('', 'INBOX.spam'),
|
|||
|
|
('', 'INBOX.Trash'),
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
# 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)
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
).scalars().all()
|
|||
|
|
existing_by_imap: dict[str, MailFolder] = {
|
|||
|
|
f.imap_name: f for f in existing_db_folders
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 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:
|
|||
|
|
# Try matching by leaf component using detected delimiter
|
|||
|
|
leaf = db_folder.imap_name.rsplit(imap_delimiter, 1)[-1]
|
|||
|
|
for srv_name in imap_names_from_server:
|
|||
|
|
srv_leaf = srv_name.rsplit(imap_delimiter, 1)[-1]
|
|||
|
|
if srv_leaf.lower() == leaf.lower():
|
|||
|
|
db_folder.imap_name = srv_name
|
|||
|
|
existing_by_imap[srv_name] = db_folder
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
# 3) Create/update folders in DB
|
|||
|
|
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,
|
|||
|
|
}
|
|||
|
|
db_folders = _build_folder_hierarchy(
|
|||
|
|
imap_folders, account.id, tenant_id, existing_by_imap, imap_delimiter,
|
|||
|
|
folder_mapping=folder_mapping,
|
|||
|
|
)
|
|||
|
|
for folder in db_folders:
|
|||
|
|
if folder.id is None:
|
|||
|
|
db.add(folder)
|
|||
|
|
await db.flush()
|
|||
|
|
|
|||
|
|
# 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:
|
|||
|
|
if imap_delimiter in folder.imap_name:
|
|||
|
|
parts = folder.imap_name.split(imap_delimiter)
|
|||
|
|
parent_imap = imap_delimiter.join(parts[:-1])
|
|||
|
|
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()
|
|||
|
|
|
|||
|
|
# 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
|
|||
|
|
new_mails: list[dict] = []
|
|||
|
|
|
|||
|
|
# 4) Sync emails for each folder (limit to last 50 per folder)
|
|||
|
|
for imap_name, folder in folder_by_imap.items():
|
|||
|
|
try:
|
|||
|
|
# 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)
|
|||
|
|
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():
|
|||
|
|
payload_bytes = part.get_payload(decode=True) or b""
|
|||
|
|
content_id = part.get("Content-ID", None)
|
|||
|
|
if content_id:
|
|||
|
|
content_id = content_id.strip("<>")
|
|||
|
|
attachments.append(
|
|||
|
|
{
|
|||
|
|
"filename": part.get_filename(),
|
|||
|
|
"mime_type": ct,
|
|||
|
|
"size": len(payload_bytes),
|
|||
|
|
"content": payload_bytes,
|
|||
|
|
"content_id": content_id,
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
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:
|
|||
|
|
"""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
|
|||
|
|
|
|||
|
|
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}"
|
|||
|
|
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", "")
|
|||
|
|
|
|||
|
|
# 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:
|
|||
|
|
logger.debug("Ignored exception in mail service", exc_info=True)
|
|||
|
|
|
|||
|
|
# Compute thread_id from References/In-Reply-To
|
|||
|
|
thread_id = _compute_thread_id(message_id, refs, in_reply_to)
|
|||
|
|
|
|||
|
|
# 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.
|
|||
|
|
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()
|
|||
|
|
|
|||
|
|
if not existing_mail:
|
|||
|
|
existing_mail = (
|
|||
|
|
await db.execute(
|
|||
|
|
select(Mail).where(
|
|||
|
|
and_(
|
|||
|
|
Mail.account_id == account.id,
|
|||
|
|
Mail.message_id == message_id,
|
|||
|
|
Mail.folder_id == folder.id,
|
|||
|
|
Mail.tenant_id == tenant_id,
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
).scalar_one_or_none()
|
|||
|
|
|
|||
|
|
if existing_mail:
|
|||
|
|
# 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
|
|||
|
|
|
|||
|
|
|
|||
|
|
# 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(
|
|||
|
|
select(func.count()).select_from(MailAttachment).where(
|
|||
|
|
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:
|
|||
|
|
raw_filename = att_data["filename"] or "attachment"
|
|||
|
|
decoded_filename = _decode_mime_filename(raw_filename)
|
|||
|
|
storage_path = await _save_attachment_to_storage(
|
|||
|
|
existing_mail.id, decoded_filename, att_data["content"]
|
|||
|
|
)
|
|||
|
|
attachment = MailAttachment(
|
|||
|
|
tenant_id=tenant_id,
|
|||
|
|
mail_id=existing_mail.id,
|
|||
|
|
filename=_sanitize_filename(decoded_filename),
|
|||
|
|
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()
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
# Collect for new-mail notifications
|
|||
|
|
new_mails.append({
|
|||
|
|
"from": from_addr,
|
|||
|
|
"subject": subject,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
# Save attachments to storage and DB
|
|||
|
|
for att_data in attachments:
|
|||
|
|
try:
|
|||
|
|
# Decode MIME-encoded filenames (e.g. =?utf-8?q?...?=)
|
|||
|
|
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,
|
|||
|
|
content_id=att_data.get("content_id"),
|
|||
|
|
)
|
|||
|
|
db.add(attachment)
|
|||
|
|
except Exception as att_err:
|
|||
|
|
logger.warning(f"Failed to save attachment for new mail {mail.id}: {att_err}")
|
|||
|
|
continue
|
|||
|
|
await db.flush()
|
|||
|
|
|
|||
|
|
# Vanished-UID check: delete DB mails whose UID no longer exists on IMAP server
|
|||
|
|
try:
|
|||
|
|
vanished_search = await client.uid_search('ALL')
|
|||
|
|
vanished_raw = vanished_search[1][0] if vanished_search[1] and vanished_search[1][0] else b''
|
|||
|
|
if isinstance(vanished_raw, (bytes, bytearray)):
|
|||
|
|
imap_uids_set = {u.decode() if isinstance(u, bytes) else str(u) for u in vanished_raw.split()}
|
|||
|
|
else:
|
|||
|
|
imap_uids_set = set()
|
|||
|
|
db_mails = (
|
|||
|
|
await db.execute(
|
|||
|
|
select(Mail).where(
|
|||
|
|
and_(
|
|||
|
|
Mail.folder_id == folder.id,
|
|||
|
|
Mail.tenant_id == tenant_id,
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
).scalars().all()
|
|||
|
|
for db_mail in db_mails:
|
|||
|
|
if db_mail.imap_uid:
|
|||
|
|
if db_mail.imap_uid not in imap_uids_set:
|
|||
|
|
logger.info("imap_sync_account: deleting vanished mail %s (UID %s no longer on server)", db_mail.id, db_mail.imap_uid)
|
|||
|
|
await db.delete(db_mail)
|
|||
|
|
elif db_mail.message_id:
|
|||
|
|
# Mail has no UID — try to find it on IMAP by Message-ID
|
|||
|
|
try:
|
|||
|
|
mid_search = await client.uid_search(f'HEADER Message-ID "{db_mail.message_id}"')
|
|||
|
|
mid_raw = mid_search[1][0] if mid_search[1] and mid_search[1][0] else b''
|
|||
|
|
if isinstance(mid_raw, (bytes, bytearray)) and mid_raw:
|
|||
|
|
found_uids = mid_raw.decode().split()
|
|||
|
|
if found_uids:
|
|||
|
|
db_mail.imap_uid = found_uids[0]
|
|||
|
|
logger.info("imap_sync_account: found UID %s for mail %s via Message-ID", found_uids[0], db_mail.id)
|
|||
|
|
else:
|
|||
|
|
logger.info("imap_sync_account: deleting mail %s (no UID, not found on server by Message-ID)", db_mail.id)
|
|||
|
|
await db.delete(db_mail)
|
|||
|
|
else:
|
|||
|
|
logger.info("imap_sync_account: deleting mail %s (no UID, not found on server by Message-ID)", db_mail.id)
|
|||
|
|
await db.delete(db_mail)
|
|||
|
|
except Exception as mid_exc:
|
|||
|
|
logger.warning("imap_sync_account: Message-ID search failed for mail %s: %s", db_mail.id, mid_exc)
|
|||
|
|
await db.flush()
|
|||
|
|
except Exception as vanished_exc:
|
|||
|
|
logger.warning("imap_sync_account: vanished-UID check failed for folder %s: %s", folder.imap_name, vanished_exc)
|
|||
|
|
|
|||
|
|
# 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)
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
).scalar()
|
|||
|
|
unread = (
|
|||
|
|
await db.execute(
|
|||
|
|
select(func.count()).select_from(Mail).where(
|
|||
|
|
and_(
|
|||
|
|
Mail.folder_id == folder.id,
|
|||
|
|
Mail.tenant_id == tenant_id,
|
|||
|
|
Mail.is_seen.is_(False),
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
).scalar()
|
|||
|
|
folder.total_count = total or 0
|
|||
|
|
folder.unread_count = unread or 0
|
|||
|
|
|
|||
|
|
await db.flush()
|
|||
|
|
|
|||
|
|
except Exception:
|
|||
|
|
# Skip folders that can't be selected (e.g. no select permission)
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# ── 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:
|
|||
|
|
logger.debug("Ignored exception in mail service", exc_info=True)
|
|||
|
|
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:
|
|||
|
|
logger.debug("Ignored exception in mail service", exc_info=True)
|
|||
|
|
await db.flush()
|
|||
|
|
except Exception:
|
|||
|
|
logger.debug("Ignored exception in mail service", exc_info=True)
|
|||
|
|
|
|||
|
|
await db.flush()
|
|||
|
|
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) ───
|