intermittent commit

This commit is contained in:
2026-07-14 13:22:11 +02:00
parent 4865de5007
commit b0337b2d26
28 changed files with 2264 additions and 490 deletions

View File

@@ -1,13 +1,20 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
import dataclasses
from types import SimpleNamespace
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from govoplan_mail.backend.schemas import (
MailAddressLookupCandidate,
MailAddressLookupResponse,
MailConnectionTestResponse,
MailImapFolderListResponse,
MailImapFolderResponse,
MailMailboxBootstrapResponse,
MailImapTestRequest,
MailMailboxAttachmentResponse,
MailMailboxMessageDetailResponse,
@@ -33,7 +40,15 @@ from govoplan_core.core.change_sequence import (
sequence_watermark_is_expired,
)
from govoplan_core.core.pagination import KeysetCursorError, decode_keyset_cursor, encode_keyset_cursor, keyset_query_fingerprint
from govoplan_core.db.session import get_session
from govoplan_core.db.session import get_database, get_session
from govoplan_mail.backend.mailbox_index import (
begin_mailbox_refresh,
cache_mailbox_folders,
cache_mailbox_messages,
cached_mailbox_folders,
cached_mailbox_message_page,
finish_mailbox_refresh,
)
from govoplan_mail.backend.mail_profiles import (
MailProfileError,
create_mail_server_profile,
@@ -49,7 +64,8 @@ from govoplan_mail.backend.mail_profiles import (
update_mail_server_profile,
)
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfigurationError, get_imap_message, list_imap_folders, list_imap_messages, test_imap_login
from govoplan_mail.backend.runtime import get_registry
from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfigurationError, get_imap_message, list_imap_folders, list_imap_messages, load_imap_mailbox_bootstrap, test_imap_login
from govoplan_mail.backend.sending.smtp import test_smtp_login
router = APIRouter(prefix="/mail", tags=["mail"])
@@ -62,6 +78,39 @@ MAIL_PROFILE_RESOURCE = "mail_profile"
MAIL_POLICY_RESOURCE = "mail_profile_policy"
MAILBOX_MESSAGES_CURSOR_SCOPE = "mail.mailbox.messages.v1"
DEFAULT_MAILBOX_MESSAGE_LIMIT = 50
CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup"
def _capability_payload(value: object) -> dict[str, Any]:
if dataclasses.is_dataclass(value):
return dataclasses.asdict(value)
if isinstance(value, dict):
return dict(value)
payload: dict[str, Any] = {}
for key in (
"contact_id",
"address_book_id",
"display_name",
"email",
"email_label",
"organization",
"role_title",
"tags",
"source_kind",
"source_ref",
"source_revision",
"provenance",
):
if hasattr(value, key):
payload[key] = getattr(value, key)
return payload
def _registry_capability(name: str) -> object | None:
registry = get_registry()
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(name):
return None
return registry.capability(name)
def _require_scope(principal: ApiPrincipal, scope: str) -> None:
@@ -338,6 +387,132 @@ def _next_mailbox_cursor(
)
def _mailbox_folder_response(
result,
*,
from_cache: bool = False,
refreshing: bool = False,
indexed_at=None,
) -> MailImapFolderListResponse:
folders = [MailImapFolderResponse(name=item.name, flags=item.flags, message_count=item.message_count, unseen_count=item.unseen_count) for item in result.folders]
return MailImapFolderListResponse(
ok=True,
host=result.host,
port=result.port,
security=result.security,
message=f"Found {len(folders)} IMAP folder(s).",
folders=folders,
detected_sent_folder=result.detected_sent_folder,
from_cache=from_cache,
refreshing=refreshing,
indexed_at=indexed_at,
)
def _mailbox_messages_response(
*,
profile_id: str,
folder: str,
host: str | None,
port: int | None,
security: str | None,
total_count: int,
offset: int,
limit: int,
cursor: str | None,
next_cursor: str | None,
cursor_stable: bool,
full: bool,
messages,
from_cache: bool = False,
refreshing: bool = False,
indexed_at=None,
) -> MailMailboxMessageListResponse:
return MailMailboxMessageListResponse(
profile_id=profile_id,
folder=folder,
host=host,
port=port,
security=security,
total_count=total_count,
offset=offset,
limit=limit,
cursor=cursor,
next_cursor=next_cursor,
cursor_stable=cursor_stable,
full=full,
from_cache=from_cache,
refreshing=refreshing,
indexed_at=indexed_at,
messages=[_mailbox_summary_response(message) for message in messages],
)
def _schedule_mailbox_refresh(
background_tasks: BackgroundTasks,
*,
tenant_id: str,
profile_id: str,
folder: str,
limit: int,
offset: int = 0,
include_folder_status: bool = False,
) -> bool:
if not begin_mailbox_refresh(tenant_id, profile_id, folder):
return False
background_tasks.add_task(
_refresh_mailbox_index_task,
tenant_id=tenant_id,
profile_id=profile_id,
folder=folder,
limit=limit,
offset=offset,
include_folder_status=include_folder_status,
)
return True
def _refresh_mailbox_index_task(
*,
tenant_id: str,
profile_id: str,
folder: str,
limit: int,
offset: int,
include_folder_status: bool,
) -> None:
try:
with get_database().session() as session:
imap = _imap_config_for_profile(session, tenant_id=tenant_id, profile_id=profile_id)
result = load_imap_mailbox_bootstrap(
imap_config=imap,
folder=folder,
limit=limit,
offset=offset,
include_folder_status=include_folder_status,
)
cache_mailbox_folders(session, tenant_id=tenant_id, profile_id=profile_id, result=result.folders)
cache_mailbox_messages(session, tenant_id=tenant_id, profile_id=profile_id, result=result.messages)
session.commit()
except Exception:
# Background refresh is opportunistic. Foreground requests will surface
# concrete IMAP errors when no usable cache is available.
pass
finally:
finish_mailbox_refresh(tenant_id, profile_id, folder)
def _cached_folder_selection(folders, requested: str, detected_sent_folder: str | None = None) -> str:
names = {folder.name for folder in folders}
if requested in names:
return requested
if "INBOX" in names:
return "INBOX"
if detected_sent_folder and detected_sent_folder in names:
return detected_sent_folder
return folders[0].name if folders else requested
def _imap_config_for_profile(session: Session, *, tenant_id: str, profile_id: str):
profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=profile_id, require_active=True)
imap = imap_config_from_profile(profile)
@@ -378,6 +553,24 @@ def _full_mail_settings_delta_response(
)
@router.get("/address-lookup", response_model=MailAddressLookupResponse)
def lookup_mail_addresses(
query: str = Query(min_length=1),
limit: int = Query(default=25, ge=1, le=100),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> MailAddressLookupResponse:
_require_scope(principal, "mail:profile:use")
capability = _registry_capability(CAPABILITY_ADDRESSES_LOOKUP)
if capability is None or not hasattr(capability, "lookup"):
return MailAddressLookupResponse(available=False, candidates=[])
candidates = getattr(capability, "lookup")(session, principal, query=query, limit=limit)
return MailAddressLookupResponse(
available=True,
candidates=[MailAddressLookupCandidate.model_validate(_capability_payload(candidate)) for candidate in candidates],
)
@router.get("/settings/delta", response_model=MailSettingsDeltaResponse)
def mail_settings_delta(
scope_type: str = Query(default="tenant"),
@@ -711,8 +904,7 @@ def list_profile_imap_folders(
if imap is None:
raise MailProfileError("Mail-server profile has no IMAP configuration")
result = list_imap_folders(imap_config=imap)
folders = [MailImapFolderResponse(name=item.name, flags=item.flags, message_count=item.message_count, unseen_count=item.unseen_count) for item in result.folders]
return MailImapFolderListResponse(ok=True, host=result.host, port=result.port, security=result.security, message=f"Found {len(folders)} IMAP folder(s).", folders=folders, detected_sent_folder=result.detected_sent_folder)
return _mailbox_folder_response(result)
except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except Exception as exc:
@@ -722,15 +914,169 @@ def list_profile_imap_folders(
@router.get("/profiles/{profile_id}/mailbox/folders", response_model=MailImapFolderListResponse)
def list_profile_mailbox_folders(
profile_id: str,
background_tasks: BackgroundTasks,
include_status: bool = Query(default=False),
refresh: bool = False,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_mailbox_read_scope(principal)
try:
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
result = list_imap_folders(imap_config=imap)
folders = [MailImapFolderResponse(name=item.name, flags=item.flags, message_count=item.message_count, unseen_count=item.unseen_count) for item in result.folders]
return MailImapFolderListResponse(ok=True, host=result.host, port=result.port, security=result.security, message=f"Found {len(folders)} IMAP folder(s).", folders=folders, detected_sent_folder=result.detected_sent_folder)
if not include_status and not refresh:
cached = cached_mailbox_folders(session, tenant_id=principal.tenant_id, profile_id=profile_id)
if cached is not None:
refreshing = cached.stale and _schedule_mailbox_refresh(
background_tasks,
tenant_id=principal.tenant_id,
profile_id=profile_id,
folder="INBOX",
limit=DEFAULT_MAILBOX_MESSAGE_LIMIT,
include_folder_status=include_status,
)
result = SimpleNamespace(
host=imap.host,
port=imap.port,
security=imap.security.value,
folders=cached.folders,
detected_sent_folder=None,
)
return _mailbox_folder_response(result, from_cache=True, refreshing=refreshing, indexed_at=cached.indexed_at)
result = list_imap_folders(imap_config=imap, include_status=include_status)
cache_mailbox_folders(session, tenant_id=principal.tenant_id, profile_id=profile_id, result=result)
session.commit()
return _mailbox_folder_response(result)
except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except ImapConfigurationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
except ImapAppendError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
@router.get("/profiles/{profile_id}/mailbox/bootstrap", response_model=MailMailboxBootstrapResponse)
def bootstrap_profile_mailbox(
profile_id: str,
background_tasks: BackgroundTasks,
folder: str = Query(default="INBOX", min_length=1, max_length=255),
limit: int = Query(default=DEFAULT_MAILBOX_MESSAGE_LIMIT, ge=1, le=100),
offset: int = Query(default=0, ge=0, le=100000),
refresh: bool = False,
include_status: bool = Query(default=False),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> MailMailboxBootstrapResponse:
_require_mailbox_read_scope(principal)
try:
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
if not refresh:
cached_folders = cached_mailbox_folders(session, tenant_id=principal.tenant_id, profile_id=profile_id)
if cached_folders is not None:
selected_folder = _cached_folder_selection(cached_folders.folders, folder)
cached_messages = cached_mailbox_message_page(
session,
tenant_id=principal.tenant_id,
profile_id=profile_id,
folder=selected_folder,
limit=limit,
offset=offset,
)
if cached_messages is not None:
refreshing = (cached_folders.stale or cached_messages.stale) and _schedule_mailbox_refresh(
background_tasks,
tenant_id=principal.tenant_id,
profile_id=profile_id,
folder=selected_folder,
limit=limit,
offset=offset,
include_folder_status=include_status,
)
folder_result = SimpleNamespace(
host=imap.host,
port=imap.port,
security=imap.security.value,
folders=cached_folders.folders,
detected_sent_folder=None,
)
next_cursor, cursor_stable = _next_mailbox_cursor(
tenant_id=principal.tenant_id,
profile_id=profile_id,
folder=cached_messages.folder,
limit=cached_messages.limit,
offset=cached_messages.offset,
total_count=cached_messages.total_count,
uidvalidity=cached_messages.uidvalidity,
messages=cached_messages.messages,
)
return MailMailboxBootstrapResponse(
profile_id=profile_id,
folder=cached_messages.folder,
folders=_mailbox_folder_response(
folder_result,
from_cache=True,
refreshing=refreshing,
indexed_at=cached_folders.indexed_at,
),
messages=_mailbox_messages_response(
profile_id=profile_id,
folder=cached_messages.folder,
host=imap.host,
port=imap.port,
security=imap.security.value,
total_count=cached_messages.total_count,
offset=cached_messages.offset,
limit=cached_messages.limit,
cursor=None,
next_cursor=next_cursor,
cursor_stable=cursor_stable,
full=not cursor_stable,
messages=cached_messages.messages,
from_cache=True,
refreshing=refreshing,
indexed_at=cached_messages.indexed_at,
),
)
result = load_imap_mailbox_bootstrap(
imap_config=imap,
folder=folder,
limit=limit,
offset=offset,
include_folder_status=include_status,
)
cache_mailbox_folders(session, tenant_id=principal.tenant_id, profile_id=profile_id, result=result.folders)
cache_mailbox_messages(session, tenant_id=principal.tenant_id, profile_id=profile_id, result=result.messages)
session.commit()
next_cursor, cursor_stable = _next_mailbox_cursor(
tenant_id=principal.tenant_id,
profile_id=profile_id,
folder=result.messages.folder,
limit=result.messages.limit,
offset=result.messages.offset,
total_count=result.messages.total_count,
uidvalidity=result.messages.uidvalidity,
messages=result.messages.messages,
)
return MailMailboxBootstrapResponse(
profile_id=profile_id,
folder=result.messages.folder,
folders=_mailbox_folder_response(result.folders),
messages=_mailbox_messages_response(
profile_id=profile_id,
folder=result.messages.folder,
host=result.messages.host,
port=result.messages.port,
security=result.messages.security,
total_count=result.messages.total_count,
offset=result.messages.offset,
limit=result.messages.limit,
cursor=None,
next_cursor=next_cursor,
cursor_stable=cursor_stable,
full=not cursor_stable,
messages=result.messages.messages,
),
)
except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except ImapConfigurationError as exc:
@@ -742,10 +1088,12 @@ def list_profile_mailbox_folders(
@router.get("/profiles/{profile_id}/mailbox/messages", response_model=MailMailboxMessageListResponse)
def list_profile_mailbox_messages(
profile_id: str,
background_tasks: BackgroundTasks,
folder: str = Query(default="INBOX", min_length=1, max_length=255),
limit: int | None = Query(default=None, ge=1, le=100),
offset: int = Query(default=0, ge=0, le=100000),
cursor: str | None = None,
refresh: bool = False,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
@@ -755,6 +1103,55 @@ def list_profile_mailbox_messages(
effective_limit = _mailbox_cursor_limit(cursor, limit)
fingerprint = _mailbox_cursor_fingerprint(tenant_id=principal.tenant_id, profile_id=profile_id, folder=folder, limit=effective_limit)
effective_offset, after_uid, expected_uidvalidity, cursor_values = _mailbox_cursor_position(cursor, fingerprint=fingerprint, offset=offset)
if not refresh:
cached = cached_mailbox_message_page(
session,
tenant_id=principal.tenant_id,
profile_id=profile_id,
folder=folder,
limit=effective_limit,
offset=effective_offset,
)
cache_matches_cursor = cached is not None and (
cursor is None or (expected_uidvalidity is not None and cached.uidvalidity == expected_uidvalidity)
)
if cached is not None and cache_matches_cursor:
refreshing = cached.stale and _schedule_mailbox_refresh(
background_tasks,
tenant_id=principal.tenant_id,
profile_id=profile_id,
folder=folder,
limit=effective_limit,
offset=effective_offset,
)
next_cursor, cursor_stable = _next_mailbox_cursor(
tenant_id=principal.tenant_id,
profile_id=profile_id,
folder=cached.folder,
limit=cached.limit,
offset=cached.offset,
total_count=cached.total_count,
uidvalidity=cached.uidvalidity,
messages=cached.messages,
)
return _mailbox_messages_response(
profile_id=profile_id,
folder=cached.folder,
host=imap.host,
port=imap.port,
security=imap.security.value,
total_count=cached.total_count,
offset=cached.offset,
limit=cached.limit,
cursor=cursor,
next_cursor=next_cursor,
cursor_stable=cursor_stable,
full=not cursor_stable,
messages=cached.messages,
from_cache=True,
refreshing=refreshing,
indexed_at=cached.indexed_at,
)
result = list_imap_messages(
imap_config=imap,
folder=folder,
@@ -763,6 +1160,8 @@ def list_profile_mailbox_messages(
after_uid=after_uid,
expected_uidvalidity=expected_uidvalidity,
)
cache_mailbox_messages(session, tenant_id=principal.tenant_id, profile_id=profile_id, result=result)
session.commit()
full = bool(cursor_values is not None and result.cursor_reset)
next_cursor, cursor_stable = _next_mailbox_cursor(
tenant_id=principal.tenant_id,
@@ -774,7 +1173,7 @@ def list_profile_mailbox_messages(
uidvalidity=result.uidvalidity,
messages=result.messages,
)
return MailMailboxMessageListResponse(
return _mailbox_messages_response(
profile_id=profile_id,
folder=result.folder,
host=result.host,
@@ -787,7 +1186,7 @@ def list_profile_mailbox_messages(
next_cursor=next_cursor,
cursor_stable=cursor_stable,
full=full or not cursor_stable,
messages=[_mailbox_summary_response(message) for message in result.messages],
messages=result.messages,
)
except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc