diff --git a/README.md b/README.md index 9b783eb..a498470 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,11 @@ GovOPlaN Mail is the mail transport module. It owns reusable SMTP/IMAP profile m This repository owns: - backend module manifest `mail` -- mail permissions such as `mail:profile:read`, `mail:profile:write`, `mail:profile:use`, and `mail:profile:test` +- mail permissions such as `mail:profile:read`, `mail:profile:write`, `mail:profile:use`, `mail:profile:test`, and `mail:mailbox:read` - SMTP/IMAP profile models, policy checks, encrypted credential storage, and profile resolution - SMTP send and IMAP append adapters, including mock transports for development - development mock mailbox endpoints used by test-send flows -- WebUI package `@govoplan/mail-webui` with profile and policy management components +- WebUI package `@govoplan/mail-webui` with profile management, policy management, and read-only mailbox components Core owns auth, tenants, RBAC evaluation, database/session primitives, secret helpers, CSRF/API helpers, and shell layout. @@ -46,6 +46,6 @@ Frontend package: @govoplan/mail-webui ``` -The campaign module depends on this module for sending, append-to-Sent behavior, profile selection, and upcoming read-only mailbox inspection. +The campaign module depends on this module for sending, append-to-Sent behavior, profile selection, and read-only mailbox inspection. Platform RBAC and governance rules are documented in `govoplan-core/docs/`. diff --git a/src/govoplan_mail/backend/manifest.py b/src/govoplan_mail/backend/manifest.py index 70f2eb5..eec0a15 100644 --- a/src/govoplan_mail/backend/manifest.py +++ b/src/govoplan_mail/backend/manifest.py @@ -2,7 +2,7 @@ from __future__ import annotations from pathlib import Path -from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, PermissionDefinition, RoleTemplate +from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate from govoplan_core.db.base import Base from govoplan_mail.backend.db import models as mail_models # noqa: F401 - populate Mail ORM metadata @@ -25,6 +25,7 @@ PERMISSIONS = ( _permission("mail:profile:read", "View mail profiles", "Inspect reusable SMTP/IMAP profile metadata."), _permission("mail:profile:use", "Use mail profiles", "Select an approved mail profile for delivery."), _permission("mail:profile:test", "Test mail profiles", "Run SMTP/IMAP connection tests."), + _permission("mail:mailbox:read", "Read mailboxes", "List IMAP folders and inspect messages without mutating mailbox state."), _permission("mail:profile:write", "Manage mail profiles", "Create and edit reusable mail profiles."), _permission("mail:secret:manage", "Manage mail secrets", "Create or replace stored SMTP/IMAP credentials."), ) @@ -38,6 +39,7 @@ ROLE_TEMPLATES = ( "mail:profile:read", "mail:profile:use", "mail:profile:test", + "mail:mailbox:read", "mail:profile:write", "mail:secret:manage", ), @@ -46,7 +48,7 @@ ROLE_TEMPLATES = ( slug="mail_profile_user", name="Mail profile user", description="Use and test approved mail profiles without reading secrets.", - permissions=("mail:profile:read", "mail:profile:use", "mail:profile:test"), + permissions=("mail:profile:read", "mail:profile:use", "mail:profile:test", "mail:mailbox:read"), ), ) @@ -69,7 +71,12 @@ manifest = ModuleManifest( permissions=PERMISSIONS, route_factory=_mail_router, role_templates=ROLE_TEMPLATES, - frontend=FrontendModule(module_id="mail", package_name="@govoplan/mail-webui"), + nav_items=(NavItem(path="/mail", label="Mail", icon="mail", required_any=("mail:mailbox:read",), order=50),), + frontend=FrontendModule( + module_id="mail", + package_name="@govoplan/mail-webui", + nav_items=(NavItem(path="/mail", label="Mail", icon="mail", required_any=("mail:mailbox:read",), order=50),), + ), migration_spec=MigrationSpec( module_id="mail", metadata=Base.metadata, diff --git a/src/govoplan_mail/backend/router.py b/src/govoplan_mail/backend/router.py index d92cacd..7f69f9c 100644 --- a/src/govoplan_mail/backend/router.py +++ b/src/govoplan_mail/backend/router.py @@ -7,6 +7,11 @@ from govoplan_mail.backend.schemas import ( MailImapFolderListResponse, MailImapFolderResponse, MailImapTestRequest, + MailMailboxAttachmentResponse, + MailMailboxMessageDetailResponse, + MailMailboxMessageListResponse, + MailMailboxMessageResponse, + MailMailboxMessageSummaryResponse, MailProfilePolicyResponse, MailProfilePolicyUpdateRequest, MailServerProfileCreateRequest, @@ -30,7 +35,7 @@ from govoplan_mail.backend.mail_profiles import ( smtp_config_from_profile, update_mail_server_profile, ) -from govoplan_mail.backend.sending.imap import list_imap_folders, test_imap_login +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.sending.smtp import test_smtp_login from sqlalchemy.orm import Session @@ -61,6 +66,11 @@ def _require_profile_credentials_scope(principal: ApiPrincipal, scope_type: str) _require_scope(principal, "mail:secret:manage") +def _require_mailbox_read_scope(principal: ApiPrincipal) -> None: + _require_scope(principal, "mail:mailbox:read") + _require_scope(principal, "mail:profile:use") + + def _require_policy_read_scope(principal: ApiPrincipal, scope_type: str) -> None: if scope_type == "system": _require_scope(principal, "system:settings:read") @@ -83,6 +93,51 @@ def _profile_response(profile) -> MailServerProfileResponse: return MailServerProfileResponse.model_validate(profile_response_payload(profile)) +def _mailbox_summary_response(message) -> MailMailboxMessageSummaryResponse: + return MailMailboxMessageSummaryResponse( + uid=message.uid, + folder=message.folder, + subject=message.subject, + from_header=message.from_header, + to_header=message.to_header, + cc_header=message.cc_header, + date=message.date, + message_id=message.message_id, + flags=message.flags, + size_bytes=message.size_bytes, + body_preview=message.body_preview, + attachment_count=message.attachment_count, + ) + + +def _mailbox_detail_response(message) -> MailMailboxMessageDetailResponse: + return MailMailboxMessageDetailResponse( + uid=message.uid, + folder=message.folder, + subject=message.subject, + from_header=message.from_header, + to_header=message.to_header, + cc_header=message.cc_header, + date=message.date, + message_id=message.message_id, + flags=message.flags, + size_bytes=message.size_bytes, + body_preview=message.body_preview, + body_text=message.body_text, + body_html=message.body_html, + headers=message.headers, + attachments=[MailMailboxAttachmentResponse(filename=item.filename, content_type=item.content_type, size_bytes=item.size_bytes) for item in message.attachments], + ) + + +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) + if imap is None: + raise MailProfileError("Mail-server profile has no IMAP configuration") + return imap + + @router.get("/profiles", response_model=MailServerProfileListResponse) def list_profiles( include_inactive: bool = False, @@ -311,6 +366,83 @@ def list_profile_imap_folders( return MailImapFolderListResponse(ok=False, message=_safe_error_message(exc), folders=[], detected_sent_folder=None, details={"error_type": exc.__class__.__name__}) +@router.get("/profiles/{profile_id}/mailbox/folders", response_model=MailImapFolderListResponse) +def list_profile_mailbox_folders( + profile_id: str, + 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) 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) + 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_ENTITY, 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/messages", response_model=MailMailboxMessageListResponse) +def list_profile_mailbox_messages( + profile_id: str, + folder: str = Query(default="INBOX", min_length=1, max_length=255), + limit: int = Query(default=50, ge=1, le=100), + 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_messages(imap_config=imap, folder=folder, limit=limit) + return MailMailboxMessageListResponse( + profile_id=profile_id, + folder=result.folder, + host=result.host, + port=result.port, + security=result.security, + messages=[_mailbox_summary_response(message) for message in result.messages], + ) + 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_ENTITY, 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/messages/{message_uid}", response_model=MailMailboxMessageResponse) +def get_profile_mailbox_message( + profile_id: str, + message_uid: str, + folder: str = Query(default="INBOX", min_length=1, max_length=255), + 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 = get_imap_message(imap_config=imap, folder=folder, uid=message_uid) + return MailMailboxMessageResponse( + profile_id=profile_id, + folder=result.folder, + host=result.host, + port=result.port, + security=result.security, + message=_mailbox_detail_response(result.message), + ) + 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_ENTITY, detail=str(exc)) from exc + except ImapAppendError as exc: + status_code = status.HTTP_404_NOT_FOUND if "not found" in str(exc).casefold() else status.HTTP_502_BAD_GATEWAY + raise HTTPException(status_code=status_code, detail=str(exc)) from exc + + def _safe_error_message(exc: Exception) -> str: text = str(exc).strip() return text or exc.__class__.__name__ diff --git a/src/govoplan_mail/backend/schemas.py b/src/govoplan_mail/backend/schemas.py index 668fb79..534516b 100644 --- a/src/govoplan_mail/backend/schemas.py +++ b/src/govoplan_mail/backend/schemas.py @@ -125,3 +125,49 @@ class MailImapFolderListResponse(BaseModel): folders: list[MailImapFolderResponse] = Field(default_factory=list) detected_sent_folder: str | None = None details: dict[str, Any] = Field(default_factory=dict) + +class MailMailboxAttachmentResponse(BaseModel): + filename: str | None = None + content_type: str + size_bytes: int + + +class MailMailboxMessageSummaryResponse(BaseModel): + uid: str + folder: str + subject: str | None = None + from_header: str | None = None + to_header: str | None = None + cc_header: str | None = None + date: str | None = None + message_id: str | None = None + flags: list[str] = Field(default_factory=list) + size_bytes: int | None = None + body_preview: str | None = None + attachment_count: int = 0 + + +class MailMailboxMessageDetailResponse(MailMailboxMessageSummaryResponse): + body_text: str | None = None + body_html: str | None = None + headers: dict[str, str] = Field(default_factory=dict) + attachments: list[MailMailboxAttachmentResponse] = Field(default_factory=list) + + +class MailMailboxMessageListResponse(BaseModel): + profile_id: str + folder: str + host: str | None = None + port: int | None = None + security: str | None = None + messages: list[MailMailboxMessageSummaryResponse] = Field(default_factory=list) + + +class MailMailboxMessageResponse(BaseModel): + profile_id: str + folder: str + host: str | None = None + port: int | None = None + security: str | None = None + message: MailMailboxMessageDetailResponse + diff --git a/src/govoplan_mail/backend/sending/imap.py b/src/govoplan_mail/backend/sending/imap.py index 502b2ad..d16dc5d 100644 --- a/src/govoplan_mail/backend/sending/imap.py +++ b/src/govoplan_mail/backend/sending/imap.py @@ -6,12 +6,18 @@ import socket import ssl import time from dataclasses import dataclass +from email import policy +from email.message import EmailMessage +from email.parser import BytesParser +from typing import Any from govoplan_mail.backend.config import ImapConfig, TransportSecurity from govoplan_mail.backend.dev.mock_mailbox import ( MOCK_IMAP_FOLDERS, consume_fail_next_imap, + get_record, is_mock_imap_host, + list_records, record_imap_append, ) @@ -55,6 +61,66 @@ class ImapFolderListResult: detected_sent_folder: str | None = None +@dataclass(frozen=True, slots=True) +class ImapMailboxAttachmentInfo: + filename: str | None + content_type: str + size_bytes: int + + +@dataclass(frozen=True, slots=True) +class ImapMailboxMessageSummary: + uid: str + folder: str + subject: str | None + from_header: str | None + to_header: str | None + cc_header: str | None + date: str | None + message_id: str | None + flags: list[str] + size_bytes: int | None + body_preview: str | None + attachment_count: int + + +@dataclass(frozen=True, slots=True) +class ImapMailboxMessageDetail: + uid: str + folder: str + subject: str | None + from_header: str | None + to_header: str | None + cc_header: str | None + date: str | None + message_id: str | None + flags: list[str] + size_bytes: int | None + body_preview: str | None + body_text: str | None + body_html: str | None + headers: dict[str, str] + attachments: list[ImapMailboxAttachmentInfo] + + +@dataclass(frozen=True, slots=True) +class ImapMailboxMessageListResult: + host: str + port: int + security: str + folder: str + messages: list[ImapMailboxMessageSummary] + + +@dataclass(frozen=True, slots=True) +class ImapMailboxMessageResult: + host: str + port: int + security: str + folder: str + message: ImapMailboxMessageDetail + + @dataclass(frozen=True, slots=True) class ImapAppendResult: host: str @@ -283,6 +349,226 @@ def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult: pass +def _parse_message(raw: bytes) -> EmailMessage: + return BytesParser(policy=policy.default).parsebytes(raw) + + +def _header_text(message: EmailMessage, name: str) -> str | None: + value = message.get(name) + return str(value) if value is not None else None + + +def _body_content(message: EmailMessage, prefer: tuple[str, ...]) -> str | None: + try: + body = message.get_body(preferencelist=prefer) if message.is_multipart() else message + if body is None: + return None + content = body.get_content() + except Exception: + return None + text = str(content).strip() + return text or None + + +def _body_preview(message: EmailMessage) -> str | None: + text = _body_content(message, ("plain", "html")) + if not text: + return None + preview = re.sub(r"\s+", " ", text).strip() + return preview[:600] or None + + +def _attachment_infos(message: EmailMessage) -> list[ImapMailboxAttachmentInfo]: + attachments: list[ImapMailboxAttachmentInfo] = [] + for part in message.iter_attachments(): + payload = part.get_payload(decode=True) or b"" + attachments.append( + ImapMailboxAttachmentInfo( + filename=part.get_filename(), + content_type=part.get_content_type(), + size_bytes=len(payload), + ) + ) + return attachments + + +def _message_summary_from_raw(*, uid: str, folder: str, raw: bytes, flags: list[str], size_bytes: int | None) -> ImapMailboxMessageSummary: + message = _parse_message(raw) + attachments = _attachment_infos(message) + return ImapMailboxMessageSummary( + uid=uid, + folder=folder, + subject=_header_text(message, "Subject"), + from_header=_header_text(message, "From"), + to_header=_header_text(message, "To"), + cc_header=_header_text(message, "Cc"), + date=_header_text(message, "Date"), + message_id=_header_text(message, "Message-ID"), + flags=flags, + size_bytes=size_bytes if size_bytes is not None else len(raw), + body_preview=_body_preview(message), + attachment_count=len(attachments), + ) + + +def _message_detail_from_raw(*, uid: str, folder: str, raw: bytes, flags: list[str], size_bytes: int | None) -> ImapMailboxMessageDetail: + message = _parse_message(raw) + attachments = _attachment_infos(message) + return ImapMailboxMessageDetail( + uid=uid, + folder=folder, + subject=_header_text(message, "Subject"), + from_header=_header_text(message, "From"), + to_header=_header_text(message, "To"), + cc_header=_header_text(message, "Cc"), + date=_header_text(message, "Date"), + message_id=_header_text(message, "Message-ID"), + flags=flags, + size_bytes=size_bytes if size_bytes is not None else len(raw), + body_preview=_body_preview(message), + body_text=_body_content(message, ("plain",)), + body_html=_body_content(message, ("html",)), + headers={str(key): str(value) for key, value in message.items()}, + attachments=attachments, + ) + + +def _parse_fetch_response(data: list[Any] | tuple[Any, ...] | None) -> tuple[str | None, list[str], int | None, bytes]: + metadata_parts: list[str] = [] + raw: bytes | None = None + for item in data or []: + if isinstance(item, tuple): + metadata_parts.append(_decode_item(item[0])) + if isinstance(item[1], bytes): + raw = item[1] + else: + metadata_parts.append(_decode_item(item)) + metadata = " ".join(part for part in metadata_parts if part) + uid_match = re.search(r"\bUID\s+(\d+)", metadata, re.IGNORECASE) + size_match = re.search(r"\bRFC822\.SIZE\s+(\d+)", metadata, re.IGNORECASE) + flags_match = re.search(r"\bFLAGS\s+\(([^)]*)\)", metadata, re.IGNORECASE) + flags = flags_match.group(1).split() if flags_match else [] + if raw is None: + raise ImapAppendError(f"IMAP message fetch returned no message body: {metadata!r}", temporary=True) + return ( + uid_match.group(1) if uid_match else None, + flags, + int(size_match.group(1)) if size_match else None, + raw, + ) + + +def _select_readonly(client: imaplib.IMAP4, folder: str) -> None: + typ, data = client.select(folder, readonly=True) + if typ != "OK": + raise ImapAppendError(f"IMAP folder {folder!r} could not be opened read-only: {data!r}", temporary=False) + + +def _fetch_message_by_uid(client: imaplib.IMAP4, uid: str) -> tuple[str, list[str], int | None, bytes]: + typ, data = client.uid("fetch", str(uid), "(UID FLAGS RFC822.SIZE BODY.PEEK[])") + if typ != "OK": + raise ImapAppendError(f"IMAP message {uid!r} fetch failed: {data!r}", temporary=True) + fetched_uid, flags, size_bytes, raw = _parse_fetch_response(data) + return fetched_uid or str(uid), flags, size_bytes, raw + + +def _mock_record_folder(record: dict[str, Any]) -> str: + folder = str(record.get("folder") or "").strip() + if folder: + return folder + return "Sent" if record.get("kind") == "imap_append" else "INBOX" + + +def _mock_folder_matches(record: dict[str, Any], folder: str) -> bool: + return _mock_record_folder(record).casefold() == folder.casefold() + + +def _mock_raw_bytes(record: dict[str, Any]) -> bytes: + raw = record.get("raw_eml") + if isinstance(raw, str) and raw: + return raw.encode("utf-8", errors="replace") + headers = record.get("headers") if isinstance(record.get("headers"), dict) else {} + lines = [f"{key}: {value}" for key, value in headers.items()] + lines.append("") + lines.append(str(record.get("body_preview") or "")) + return "\n".join(lines).encode("utf-8", errors="replace") + + +def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit: int = 50) -> ImapMailboxMessageListResult: + """List mailbox messages without mutating read/unread state.""" + + host, port = _require_imap_config(imap_config) + folder = (folder or "INBOX").strip() or "INBOX" + limit = max(1, min(limit, 100)) + if is_mock_imap_host(imap_config.host): + records = [record for record in list_records(limit=500) if _mock_folder_matches(record, folder)] + messages = [ + _message_summary_from_raw( + uid=str(record.get("id") or ""), + folder=_mock_record_folder(record), + raw=_mock_raw_bytes(record), + flags=["\\Seen"] if record.get("kind") == "imap_append" else [], + size_bytes=int(record.get("size_bytes") or 0) or None, + ) + for record in records[:limit] + ] + return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages) + + client = _open_imap(imap_config) + try: + _select_readonly(client, folder) + typ, data = client.uid("search", None, "ALL") + if typ != "OK": + raise ImapAppendError(f"IMAP search failed for folder {folder!r}: {data!r}", temporary=True) + uid_bytes = data[0] if data else b"" + uids = _decode_item(uid_bytes).split() + messages: list[ImapMailboxMessageSummary] = [] + for uid in reversed(uids[-limit:]): + fetched_uid, flags, size_bytes, raw = _fetch_message_by_uid(client, uid) + messages.append(_message_summary_from_raw(uid=fetched_uid, folder=folder, raw=raw, flags=flags, size_bytes=size_bytes)) + return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages) + finally: + try: + client.logout() + except Exception: + pass + + +def get_imap_message(*, imap_config: ImapConfig, folder: str, uid: str) -> ImapMailboxMessageResult: + """Fetch one message body using BODY.PEEK in a read-only selected mailbox.""" + + host, port = _require_imap_config(imap_config) + folder = (folder or "INBOX").strip() or "INBOX" + uid = str(uid).strip() + if not uid: + raise ImapConfigurationError("Message UID is required") + + if is_mock_imap_host(imap_config.host): + record = get_record(uid, include_raw=True) + if not record or not _mock_folder_matches(record, folder): + raise ImapAppendError(f"Mock mailbox message {uid!r} was not found in folder {folder!r}", temporary=False) + message = _message_detail_from_raw( + uid=uid, + folder=_mock_record_folder(record), + raw=_mock_raw_bytes(record), + flags=["\\Seen"] if record.get("kind") == "imap_append" else [], + size_bytes=int(record.get("size_bytes") or 0) or None, + ) + return ImapMailboxMessageResult(host=host, port=port, security=imap_config.security.value, folder=folder, message=message) + + client = _open_imap(imap_config) + try: + _select_readonly(client, folder) + fetched_uid, flags, size_bytes, raw = _fetch_message_by_uid(client, uid) + message = _message_detail_from_raw(uid=fetched_uid, folder=folder, raw=raw, flags=flags, size_bytes=size_bytes) + return ImapMailboxMessageResult(host=host, port=port, security=imap_config.security.value, folder=folder, message=message) + finally: + try: + client.logout() + except Exception: + pass + + def append_message_to_sent( message_bytes: bytes, *, diff --git a/src/govoplan_mail/backend/sending/rate_limit.py b/src/govoplan_mail/backend/sending/rate_limit.py index 168ae00..06b3f85 100644 --- a/src/govoplan_mail/backend/sending/rate_limit.py +++ b/src/govoplan_mail/backend/sending/rate_limit.py @@ -21,18 +21,23 @@ def _redis_client() -> Redis: return Redis.from_url(settings.redis_url, decode_responses=True) +def _distributed_rate_limit_enabled() -> bool: + return bool(getattr(settings, "celery_enabled", False)) + + def wait_for_rate_limit(*, key: str, messages_per_minute: int, enabled: bool = True) -> RateLimitDecision: """Throttle sends across worker processes using Redis when available. The implementation stores the next allowed send timestamp per key. A Redis lock keeps multiple Celery processes from reading/updating the timestamp at - the same time. If Redis is unavailable, it falls back to no distributed wait; - the per-container Celery concurrency still protects local development. + the same time. Direct local development runs do not have a broker, so Redis + is only used when Celery/worker mode is explicitly enabled. If Redis is + unavailable, it falls back to no distributed wait. """ messages_per_minute = max(1, int(messages_per_minute or 1)) gap = 60.0 / messages_per_minute - if not enabled: + if not enabled or not _distributed_rate_limit_enabled(): return RateLimitDecision(key=key, messages_per_minute=messages_per_minute, gap_seconds=gap, waited_seconds=0.0) redis_key = f"multimailer:ratelimit:{key}:next_allowed" diff --git a/webui/src/api/mail.ts b/webui/src/api/mail.ts index 5db63b7..7189e2c 100644 --- a/webui/src/api/mail.ts +++ b/webui/src/api/mail.ts @@ -46,6 +46,52 @@ export type MailImapFolderListResponse = { }; +export type MailMailboxAttachment = { + filename?: string | null; + content_type: string; + size_bytes: number; +}; + +export type MailMailboxMessageSummary = { + uid: string; + folder: string; + subject?: string | null; + from_header?: string | null; + to_header?: string | null; + cc_header?: string | null; + date?: string | null; + message_id?: string | null; + flags?: string[]; + size_bytes?: number | null; + body_preview?: string | null; + attachment_count?: number; +}; + +export type MailMailboxMessageDetail = MailMailboxMessageSummary & { + body_text?: string | null; + body_html?: string | null; + headers?: Record; + attachments?: MailMailboxAttachment[]; +}; + +export type MailMailboxMessageListResponse = { + profile_id: string; + folder: string; + host?: string | null; + port?: number | null; + security?: MailSecurity | string | null; + messages: MailMailboxMessageSummary[]; +}; + +export type MailMailboxMessageResponse = { + profile_id: string; + folder: string; + host?: string | null; + port?: number | null; + security?: MailSecurity | string | null; + message: MailMailboxMessageDetail; +}; + export type MailServerProfile = { id: string; tenant_id?: string | null; @@ -179,6 +225,30 @@ export async function listMailProfileImapFolders(settings: ApiSettings, profileI return apiFetch(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`, { method: "POST" }); } +export async function listMailboxFolders(settings: ApiSettings, profileId: string): Promise { + return apiFetch(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/folders`); +} + +export async function listMailboxMessages( + settings: ApiSettings, + profileId: string, + folder = "INBOX", + limit = 50 +): Promise { + const params = new URLSearchParams({ folder, limit: String(limit) }); + return apiFetch(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages?${params.toString()}`); +} + +export async function getMailboxMessage( + settings: ApiSettings, + profileId: string, + folder: string, + uid: string +): Promise { + const params = new URLSearchParams({ folder }); + return apiFetch(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages/${encodeURIComponent(uid)}?${params.toString()}`); +} + export async function testSmtpSettings( settings: ApiSettings, payload: MailSmtpTestPayload diff --git a/webui/src/features/mail/MailProfileManagement.tsx b/webui/src/features/mail/MailProfileManagement.tsx index 0c15125..e66b03f 100644 --- a/webui/src/features/mail/MailProfileManagement.tsx +++ b/webui/src/features/mail/MailProfileManagement.tsx @@ -1,13 +1,20 @@ import { useEffect, useMemo, useState } from "react"; +import { MailServerSettingsPanel, type MailServerConnectionTestResult, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings } from "@govoplan/core-webui"; import { Pencil, Plus, Trash2 } from "lucide-react"; import type { ApiSettings } from "../../types"; import { createMailServerProfile, deactivateMailServerProfile, getMailProfilePolicy, + listImapFolders, + listMailProfileImapFolders, listMailServerProfiles, mailProfilePatternKeys, updateMailProfilePolicy, + testImapSettings, + testMailProfileImap, + testMailProfileSmtp, + testSmtpSettings, updateMailServerProfile, type MailCredentialPolicy, type MailImapTestPayload, @@ -28,7 +35,6 @@ import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; import Dialog from "../../components/Dialog"; import DismissibleAlert from "../../components/DismissibleAlert"; import FormField from "../../components/FormField"; -import ToggleSwitch from "../../components/ToggleSwitch"; export type MailProfileTargetOption = { id: string; @@ -290,7 +296,7 @@ export function MailProfileScopeManager({ closeDisabled={busy} footer={<>} > - + void; editing: EditingProfile; @@ -506,10 +514,101 @@ function ProfileForm({ canWrite: boolean; canManageCredentials: boolean; }) { + const [smtpTestResult, setSmtpTestResult] = useState(null); + const [imapTestResult, setImapTestResult] = useState(null); + const [folderResult, setFolderResult] = useState(null); + const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | null>(null); + const disabled = busy || !canWrite; const credentialDisabled = disabled || !canManageCredentials; - const existingHasImap = editing !== "new" && Boolean(editing?.imap); + const existingProfile = editing !== "new" ? editing : null; + const existingHasImap = Boolean(existingProfile?.imap); const imapToggleDisabled = disabled || (!canManageCredentials && existingHasImap && draft.imapEnabled); + const useSavedSmtpTest = Boolean(existingProfile && !draft.smtpPassword && existingProfile.smtp_password_configured); + const useSavedImapTest = Boolean(existingProfile && !draft.imapPassword && existingProfile.imap_password_configured); + + useEffect(() => { + setSmtpTestResult(null); + setImapTestResult(null); + setFolderResult(null); + setMailActionState(null); + }, [editing]); + + function patchSmtpSettings(patch: Partial) { + setDraft({ + ...draft, + smtpHost: patch.host !== undefined ? String(patch.host ?? "") : draft.smtpHost, + smtpPort: patch.port !== undefined ? String(patch.port ?? "") : draft.smtpPort, + smtpUsername: patch.username !== undefined ? String(patch.username ?? "") : draft.smtpUsername, + smtpPassword: patch.password !== undefined ? String(patch.password ?? "") : draft.smtpPassword, + smtpSecurity: patch.security !== undefined ? readSecurity(String(patch.security || "starttls"), "starttls") : draft.smtpSecurity, + smtpTimeout: patch.timeout_seconds !== undefined ? String(patch.timeout_seconds ?? "") : draft.smtpTimeout + }); + } + + function patchImapSettings(patch: Partial) { + setDraft({ + ...draft, + imapHost: patch.host !== undefined ? String(patch.host ?? "") : draft.imapHost, + imapPort: patch.port !== undefined ? String(patch.port ?? "") : draft.imapPort, + imapUsername: patch.username !== undefined ? String(patch.username ?? "") : draft.imapUsername, + imapPassword: patch.password !== undefined ? String(patch.password ?? "") : draft.imapPassword, + imapSecurity: patch.security !== undefined ? readSecurity(String(patch.security || "tls"), "tls") : draft.imapSecurity, + imapSentFolder: patch.sent_folder !== undefined ? String(patch.sent_folder ?? "") : draft.imapSentFolder, + imapTimeout: patch.timeout_seconds !== undefined ? String(patch.timeout_seconds ?? "") : draft.imapTimeout + }); + } + + async function runSmtpTest() { + setMailActionState("smtp"); + setSmtpTestResult(null); + try { + setSmtpTestResult(useSavedSmtpTest && existingProfile + ? await testMailProfileSmtp(settings, existingProfile.id) + : await testSmtpSettings(settings, smtpPayload(draft, false))); + } catch (err) { + setSmtpTestResult({ ok: false, protocol: "smtp", message: errorMessage(err), details: {} }); + } finally { + setMailActionState(null); + } + } + + async function runImapTest() { + if (!draft.imapEnabled) return; + setMailActionState("imap"); + setImapTestResult(null); + try { + setImapTestResult(useSavedImapTest && existingProfile + ? await testMailProfileImap(settings, existingProfile.id) + : await testImapSettings(settings, imapPayload(draft, false))); + } catch (err) { + setImapTestResult({ ok: false, protocol: "imap", message: errorMessage(err), details: {} }); + } finally { + setMailActionState(null); + } + } + + async function runFolderLookup() { + if (!draft.imapEnabled) return; + setMailActionState("folders"); + setFolderResult(null); + try { + setFolderResult(useSavedImapTest && existingProfile + ? await listMailProfileImapFolders(settings, existingProfile.id) + : await listImapFolders(settings, imapPayload(draft, false))); + } catch (err) { + setFolderResult({ ok: false, message: errorMessage(err), folders: [] }); + } finally { + setMailActionState(null); + } + } + + function useDetectedSentFolder() { + const folder = folderResult?.detected_sent_folder; + if (!folder || disabled || !draft.imapEnabled) return; + setDraft({ ...draft, imapSentFolder: folder }); + } + return (
@@ -519,29 +618,32 @@ function ProfileForm({