campaign sending prototype

This commit is contained in:
2026-06-24 16:20:44 +02:00
parent 99fee44651
commit a9d16a2c89
12 changed files with 1478 additions and 41 deletions

View File

@@ -7,11 +7,11 @@ GovOPlaN Mail is the mail transport module. It owns reusable SMTP/IMAP profile m
This repository owns: This repository owns:
- backend module manifest `mail` - 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/IMAP profile models, policy checks, encrypted credential storage, and profile resolution
- SMTP send and IMAP append adapters, including mock transports for development - SMTP send and IMAP append adapters, including mock transports for development
- development mock mailbox endpoints used by test-send flows - 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. 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 @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/`. Platform RBAC and governance rules are documented in `govoplan-core/docs/`.

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from pathlib import Path 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_core.db.base import Base
from govoplan_mail.backend.db import models as mail_models # noqa: F401 - populate Mail ORM metadata 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: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: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: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: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."), _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:read",
"mail:profile:use", "mail:profile:use",
"mail:profile:test", "mail:profile:test",
"mail:mailbox:read",
"mail:profile:write", "mail:profile:write",
"mail:secret:manage", "mail:secret:manage",
), ),
@@ -46,7 +48,7 @@ ROLE_TEMPLATES = (
slug="mail_profile_user", slug="mail_profile_user",
name="Mail profile user", name="Mail profile user",
description="Use and test approved mail profiles without reading secrets.", 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, permissions=PERMISSIONS,
route_factory=_mail_router, route_factory=_mail_router,
role_templates=ROLE_TEMPLATES, 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( migration_spec=MigrationSpec(
module_id="mail", module_id="mail",
metadata=Base.metadata, metadata=Base.metadata,

View File

@@ -7,6 +7,11 @@ from govoplan_mail.backend.schemas import (
MailImapFolderListResponse, MailImapFolderListResponse,
MailImapFolderResponse, MailImapFolderResponse,
MailImapTestRequest, MailImapTestRequest,
MailMailboxAttachmentResponse,
MailMailboxMessageDetailResponse,
MailMailboxMessageListResponse,
MailMailboxMessageResponse,
MailMailboxMessageSummaryResponse,
MailProfilePolicyResponse, MailProfilePolicyResponse,
MailProfilePolicyUpdateRequest, MailProfilePolicyUpdateRequest,
MailServerProfileCreateRequest, MailServerProfileCreateRequest,
@@ -30,7 +35,7 @@ from govoplan_mail.backend.mail_profiles import (
smtp_config_from_profile, smtp_config_from_profile,
update_mail_server_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 govoplan_mail.backend.sending.smtp import test_smtp_login
from sqlalchemy.orm import Session 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") _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: def _require_policy_read_scope(principal: ApiPrincipal, scope_type: str) -> None:
if scope_type == "system": if scope_type == "system":
_require_scope(principal, "system:settings:read") _require_scope(principal, "system:settings:read")
@@ -83,6 +93,51 @@ def _profile_response(profile) -> MailServerProfileResponse:
return MailServerProfileResponse.model_validate(profile_response_payload(profile)) 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) @router.get("/profiles", response_model=MailServerProfileListResponse)
def list_profiles( def list_profiles(
include_inactive: bool = False, 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__}) 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: def _safe_error_message(exc: Exception) -> str:
text = str(exc).strip() text = str(exc).strip()
return text or exc.__class__.__name__ return text or exc.__class__.__name__

View File

@@ -125,3 +125,49 @@ class MailImapFolderListResponse(BaseModel):
folders: list[MailImapFolderResponse] = Field(default_factory=list) folders: list[MailImapFolderResponse] = Field(default_factory=list)
detected_sent_folder: str | None = None detected_sent_folder: str | None = None
details: dict[str, Any] = Field(default_factory=dict) 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

View File

@@ -6,12 +6,18 @@ import socket
import ssl import ssl
import time import time
from dataclasses import dataclass 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.config import ImapConfig, TransportSecurity
from govoplan_mail.backend.dev.mock_mailbox import ( from govoplan_mail.backend.dev.mock_mailbox import (
MOCK_IMAP_FOLDERS, MOCK_IMAP_FOLDERS,
consume_fail_next_imap, consume_fail_next_imap,
get_record,
is_mock_imap_host, is_mock_imap_host,
list_records,
record_imap_append, record_imap_append,
) )
@@ -55,6 +61,66 @@ class ImapFolderListResult:
detected_sent_folder: str | None = None 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) @dataclass(frozen=True, slots=True)
class ImapAppendResult: class ImapAppendResult:
host: str host: str
@@ -283,6 +349,226 @@ def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
pass 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( def append_message_to_sent(
message_bytes: bytes, message_bytes: bytes,
*, *,

View File

@@ -21,18 +21,23 @@ def _redis_client() -> Redis:
return Redis.from_url(settings.redis_url, decode_responses=True) 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: def wait_for_rate_limit(*, key: str, messages_per_minute: int, enabled: bool = True) -> RateLimitDecision:
"""Throttle sends across worker processes using Redis when available. """Throttle sends across worker processes using Redis when available.
The implementation stores the next allowed send timestamp per key. A Redis The implementation stores the next allowed send timestamp per key. A Redis
lock keeps multiple Celery processes from reading/updating the timestamp at 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 same time. Direct local development runs do not have a broker, so Redis
the per-container Celery concurrency still protects local development. 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)) messages_per_minute = max(1, int(messages_per_minute or 1))
gap = 60.0 / messages_per_minute 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) 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" redis_key = f"multimailer:ratelimit:{key}:next_allowed"

View File

@@ -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<string, string>;
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 = { export type MailServerProfile = {
id: string; id: string;
tenant_id?: string | null; tenant_id?: string | null;
@@ -179,6 +225,30 @@ export async function listMailProfileImapFolders(settings: ApiSettings, profileI
return apiFetch<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`, { method: "POST" }); return apiFetch<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`, { method: "POST" });
} }
export async function listMailboxFolders(settings: ApiSettings, profileId: string): Promise<MailImapFolderListResponse> {
return apiFetch<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/folders`);
}
export async function listMailboxMessages(
settings: ApiSettings,
profileId: string,
folder = "INBOX",
limit = 50
): Promise<MailMailboxMessageListResponse> {
const params = new URLSearchParams({ folder, limit: String(limit) });
return apiFetch<MailMailboxMessageListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages?${params.toString()}`);
}
export async function getMailboxMessage(
settings: ApiSettings,
profileId: string,
folder: string,
uid: string
): Promise<MailMailboxMessageResponse> {
const params = new URLSearchParams({ folder });
return apiFetch<MailMailboxMessageResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages/${encodeURIComponent(uid)}?${params.toString()}`);
}
export async function testSmtpSettings( export async function testSmtpSettings(
settings: ApiSettings, settings: ApiSettings,
payload: MailSmtpTestPayload payload: MailSmtpTestPayload

View File

@@ -1,13 +1,20 @@
import { useEffect, useMemo, useState } from "react"; 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 { Pencil, Plus, Trash2 } from "lucide-react";
import type { ApiSettings } from "../../types"; import type { ApiSettings } from "../../types";
import { import {
createMailServerProfile, createMailServerProfile,
deactivateMailServerProfile, deactivateMailServerProfile,
getMailProfilePolicy, getMailProfilePolicy,
listImapFolders,
listMailProfileImapFolders,
listMailServerProfiles, listMailServerProfiles,
mailProfilePatternKeys, mailProfilePatternKeys,
updateMailProfilePolicy, updateMailProfilePolicy,
testImapSettings,
testMailProfileImap,
testMailProfileSmtp,
testSmtpSettings,
updateMailServerProfile, updateMailServerProfile,
type MailCredentialPolicy, type MailCredentialPolicy,
type MailImapTestPayload, type MailImapTestPayload,
@@ -28,7 +35,6 @@ import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import Dialog from "../../components/Dialog"; import Dialog from "../../components/Dialog";
import DismissibleAlert from "../../components/DismissibleAlert"; import DismissibleAlert from "../../components/DismissibleAlert";
import FormField from "../../components/FormField"; import FormField from "../../components/FormField";
import ToggleSwitch from "../../components/ToggleSwitch";
export type MailProfileTargetOption = { export type MailProfileTargetOption = {
id: string; id: string;
@@ -290,7 +296,7 @@ export function MailProfileScopeManager({
closeDisabled={busy} closeDisabled={busy}
footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveProfile()} disabled={!canWriteProfiles || busy || !draft.name.trim() || !scopeReady}>{busy ? "Saving…" : "Save profile"}</Button></>} footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveProfile()} disabled={!canWriteProfiles || busy || !draft.name.trim() || !scopeReady}>{busy ? "Saving…" : "Save profile"}</Button></>}
> >
<ProfileForm draft={draft} setDraft={setDraft} editing={editing} busy={busy} canWrite={canWriteProfiles} canManageCredentials={canManageCredentials} /> <ProfileForm settings={settings} draft={draft} setDraft={setDraft} editing={editing} busy={busy} canWrite={canWriteProfiles} canManageCredentials={canManageCredentials} />
</Dialog> </Dialog>
<ConfirmDialog <ConfirmDialog
@@ -492,6 +498,7 @@ export function MailProfilePolicyEditor({
} }
function ProfileForm({ function ProfileForm({
settings,
draft, draft,
setDraft, setDraft,
editing, editing,
@@ -499,6 +506,7 @@ function ProfileForm({
canWrite, canWrite,
canManageCredentials canManageCredentials
}: { }: {
settings: ApiSettings;
draft: ProfileDraft; draft: ProfileDraft;
setDraft: (draft: ProfileDraft) => void; setDraft: (draft: ProfileDraft) => void;
editing: EditingProfile; editing: EditingProfile;
@@ -506,10 +514,101 @@ function ProfileForm({
canWrite: boolean; canWrite: boolean;
canManageCredentials: boolean; canManageCredentials: boolean;
}) { }) {
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
const [folderResult, setFolderResult] = useState<MailServerFolderLookupResult | null>(null);
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | null>(null);
const disabled = busy || !canWrite; const disabled = busy || !canWrite;
const credentialDisabled = disabled || !canManageCredentials; 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 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<MailServerSmtpSettings>) {
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<MailServerImapSettings>) {
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 ( return (
<div className="mail-profile-form"> <div className="mail-profile-form">
<div className="admin-form-grid two-columns"> <div className="admin-form-grid two-columns">
@@ -519,29 +618,32 @@ function ProfileForm({
<FormField label="Description"><textarea rows={3} value={draft.description} disabled={disabled} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField> <FormField label="Description"><textarea rows={3} value={draft.description} disabled={disabled} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
</div> </div>
<h3>SMTP</h3> <MailServerSettingsPanel
<div className="admin-form-grid three-columns"> smtp={{ host: draft.smtpHost, port: draft.smtpPort, username: draft.smtpUsername, password: draft.smtpPassword, security: draft.smtpSecurity, timeout_seconds: draft.smtpTimeout }}
<FormField label="Host"><input value={draft.smtpHost} disabled={disabled} onChange={(event) => setDraft({ ...draft, smtpHost: event.target.value })} /></FormField> imap={{ enabled: draft.imapEnabled, host: draft.imapHost, port: draft.imapPort, username: draft.imapUsername, password: draft.imapPassword, security: draft.imapSecurity, sent_folder: draft.imapSentFolder, timeout_seconds: draft.imapTimeout }}
<FormField label="Port"><input type="number" min={1} max={65535} value={draft.smtpPort} disabled={disabled} onChange={(event) => setDraft({ ...draft, smtpPort: event.target.value })} /></FormField> onSmtpChange={patchSmtpSettings}
<FormField label="Security"><SecuritySelect value={draft.smtpSecurity} disabled={disabled} onChange={(smtpSecurity) => setDraft({ ...draft, smtpSecurity })} /></FormField> onImapChange={patchImapSettings}
<FormField label="Username"><input value={draft.smtpUsername} disabled={disabled} onChange={(event) => setDraft({ ...draft, smtpUsername: event.target.value })} /></FormField> onImapEnabledChange={(imapEnabled) => setDraft({ ...draft, imapEnabled })}
<FormField label="Password" help={editing === "new" ? undefined : "Leave empty to keep the current password."}><input type="password" value={draft.smtpPassword} disabled={credentialDisabled} onChange={(event) => setDraft({ ...draft, smtpPassword: event.target.value })} /></FormField> smtpDisabled={disabled}
<FormField label="Timeout seconds"><input type="number" min={1} value={draft.smtpTimeout} disabled={disabled} onChange={(event) => setDraft({ ...draft, smtpTimeout: event.target.value })} /></FormField> smtpCredentialDisabled={credentialDisabled}
</div> imapToggleDisabled={imapToggleDisabled}
imapServerDisabled={disabled || !draft.imapEnabled}
<div className="mail-profile-imap-heading"> imapCredentialDisabled={credentialDisabled || !draft.imapEnabled}
<h3>IMAP</h3> imapActionDisabled={disabled || !draft.imapEnabled}
<ToggleSwitch label="Enable IMAP" checked={draft.imapEnabled} disabled={imapToggleDisabled} onChange={(imapEnabled) => setDraft({ ...draft, imapEnabled })} /> smtpTitle="SMTP"
</div> imapTitle="IMAP"
<div className="admin-form-grid three-columns"> smtpTestLabel={useSavedSmtpTest ? "Test saved SMTP" : "Test SMTP"}
<FormField label="Host"><input value={draft.imapHost} disabled={disabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapHost: event.target.value })} /></FormField> imapTestLabel={useSavedImapTest ? "Test saved IMAP" : "Test IMAP"}
<FormField label="Port"><input type="number" min={1} max={65535} value={draft.imapPort} disabled={disabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapPort: event.target.value })} /></FormField> busyAction={mailActionState}
<FormField label="Security"><SecuritySelect value={draft.imapSecurity} disabled={disabled || !draft.imapEnabled} onChange={(imapSecurity) => setDraft({ ...draft, imapSecurity })} /></FormField> onTestSmtp={() => void runSmtpTest()}
<FormField label="Username"><input value={draft.imapUsername} disabled={disabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapUsername: event.target.value })} /></FormField> onTestImap={() => void runImapTest()}
<FormField label="Password" help={editing === "new" ? undefined : "Leave empty to keep the current password."}><input type="password" value={draft.imapPassword} disabled={credentialDisabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapPassword: event.target.value })} /></FormField> onLookupFolders={() => void runFolderLookup()}
<FormField label="Sent folder"><input value={draft.imapSentFolder} disabled={disabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapSentFolder: event.target.value })} /></FormField> smtpTestResult={smtpTestResult}
<FormField label="Timeout seconds"><input type="number" min={1} value={draft.imapTimeout} disabled={disabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapTimeout: event.target.value })} /></FormField> imapTestResult={imapTestResult}
</div> folderLookupResult={folderResult}
onUseDetectedFolder={useDetectedSentFolder}
useDetectedFolderDisabled={disabled || !draft.imapEnabled}
/>
</div> </div>
); );
} }
@@ -586,10 +688,6 @@ function PatternTextarea({ label, value, disabled, onChange }: { label: string;
return <FormField label={label}><textarea rows={3} value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)} placeholder="*.example.org" /></FormField>; return <FormField label={label}><textarea rows={3} value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)} placeholder="*.example.org" /></FormField>;
} }
function SecuritySelect({ value, disabled, onChange }: { value: MailSecurity; disabled: boolean; onChange: (value: MailSecurity) => void }) {
return <select value={value} disabled={disabled} onChange={(event) => onChange(readSecurity(event.target.value, value))}>{securityOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select>;
}
function TransportCell({ profile, protocol }: { profile: MailServerProfile; protocol: "smtp" | "imap" }) { function TransportCell({ profile, protocol }: { profile: MailServerProfile; protocol: "smtp" | "imap" }) {
const transport = protocol === "smtp" ? profile.smtp : profile.imap; const transport = protocol === "smtp" ? profile.smtp : profile.imap;
if (!transport) return <span className="muted">Not configured</span>; if (!transport) return <span className="muted">Not configured</span>;

View File

@@ -0,0 +1,390 @@
import { useEffect, useMemo, useState } from "react";
import { ChevronRight, Home, Mail, Paperclip, RefreshCw } from "lucide-react";
import {
Button,
DismissibleAlert,
ExplorerTree,
LoadingIndicator,
MessageDisplayPanel,
formatDateTime,
type ApiSettings
} from "@govoplan/core-webui";
import {
getMailboxMessage,
listMailboxFolders,
listMailboxMessages,
listMailServerProfiles,
type MailImapFolderResponse,
type MailMailboxMessageDetail,
type MailMailboxMessageSummary,
type MailServerProfile
} from "../../api/mail";
type MailFolderNode = {
id: string;
label: string;
folderName: string | null;
flags: string[];
children: MailFolderNode[];
};
export default function MailboxPage({ settings }: { settings: ApiSettings }) {
const [profiles, setProfiles] = useState<MailServerProfile[]>([]);
const [selectedProfileId, setSelectedProfileId] = useState("");
const [folders, setFolders] = useState<MailImapFolderResponse[]>([]);
const [selectedFolder, setSelectedFolder] = useState("INBOX");
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
const [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]);
const [selectedMessage, setSelectedMessage] = useState<MailMailboxMessageDetail | null>(null);
const [loadingProfiles, setLoadingProfiles] = useState(true);
const [loadingFolders, setLoadingFolders] = useState(false);
const [loadingMessages, setLoadingMessages] = useState(false);
const [loadingMessage, setLoadingMessage] = useState(false);
const [error, setError] = useState("");
const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) ?? null;
const imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]);
const folderTree = useMemo(() => buildMailboxFolderTree(folders), [folders]);
const selectedFolderNodeId = useMemo(() => findFolderNodeId(folderTree, selectedFolder) ?? "", [folderTree, selectedFolder]);
const busy = loadingProfiles || loadingFolders || loadingMessages || loadingMessage;
const loadingLabel = loadingProfiles ? "Loading mail profiles..." : loadingFolders ? "Loading folders..." : loadingMessages ? "Loading messages..." : "Loading message...";
useEffect(() => { void loadProfiles(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => { if (selectedProfileId) void loadFolders(selectedProfileId); }, [selectedProfileId]);
useEffect(() => { if (selectedProfileId && selectedFolder) void loadMessages(selectedProfileId, selectedFolder); }, [selectedProfileId, selectedFolder]);
useEffect(() => {
const ancestorIds = folderAncestorIds(folderTree, selectedFolder);
if (ancestorIds.length === 0) return;
setExpandedFolders((current) => {
const next = new Set(current);
ancestorIds.forEach((id) => next.add(id));
return next;
});
}, [folderTree, selectedFolder]);
async function loadProfiles() {
setLoadingProfiles(true);
setError("");
try {
const loaded = await listMailServerProfiles(settings);
const usable = loaded.filter((profile) => profile.is_active && profile.imap);
setProfiles(loaded);
setSelectedProfileId((current) => current && usable.some((profile) => profile.id === current) ? current : usable[0]?.id ?? "");
if (usable.length === 0) {
setFolders([]);
setMessages([]);
setSelectedMessage(null);
}
} catch (err) {
setError(errorText(err));
} finally {
setLoadingProfiles(false);
}
}
async function loadFolders(profileId = selectedProfileId) {
if (!profileId) return;
setLoadingFolders(true);
setError("");
try {
const response = await listMailboxFolders(settings, profileId);
if (!response.ok) throw new Error(response.message || "Mailbox folders could not be loaded.");
const loaded = response.folders?.length ? response.folders : [{ name: "INBOX", flags: [] }];
setFolders(loaded);
setExpandedFolders(new Set());
setSelectedFolder((current) => {
if (current && loaded.some((folder) => folder.name === current)) return current;
if (loaded.some((folder) => folder.name === "INBOX")) return "INBOX";
return response.detected_sent_folder || loaded[0]?.name || "INBOX";
});
} catch (err) {
setError(errorText(err));
setFolders([]);
setMessages([]);
setSelectedMessage(null);
} finally {
setLoadingFolders(false);
}
}
async function loadMessages(profileId = selectedProfileId, folder = selectedFolder) {
if (!profileId || !folder) return;
setLoadingMessages(true);
setError("");
try {
const response = await listMailboxMessages(settings, profileId, folder, 50);
setMessages(response.messages ?? []);
setSelectedMessage(null);
} catch (err) {
setError(errorText(err));
setMessages([]);
setSelectedMessage(null);
} finally {
setLoadingMessages(false);
}
}
async function openMessage(message: MailMailboxMessageSummary) {
if (!selectedProfileId || loadingMessage) return;
setLoadingMessage(true);
setError("");
try {
const response = await getMailboxMessage(settings, selectedProfileId, selectedFolder, message.uid);
setSelectedMessage(response.message);
} catch (err) {
setError(errorText(err));
} finally {
setLoadingMessage(false);
}
}
function selectProfile(profileId: string) {
setSelectedProfileId(profileId);
setFolders([]);
setMessages([]);
setSelectedMessage(null);
setExpandedFolders(new Set());
}
function openFolderNode(node: MailFolderNode) {
if (node.folderName) {
setSelectedFolder(node.folderName);
return;
}
toggleFolderNode(node);
}
function toggleFolderNode(node: MailFolderNode) {
setExpandedFolders((current) => {
const next = new Set(current);
if (next.has(node.id)) next.delete(node.id);
else next.add(node.id);
return next;
});
}
return (
<div className="workspace-data-page module-entry-page file-manager-page file-manager-fullscreen mailbox-page">
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<div className={`file-manager-shell mailbox-shell ${busy ? "is-loading" : ""}`}>
<aside className="file-tree-panel" aria-label="Mailbox folders">
<div className="file-tree-heading">Folders</div>
<div className="file-tree-list">
{folderTree.length === 0 && <p className="muted small-text mailbox-tree-empty">No folders available.</p>}
<ExplorerTree
nodes={folderTree}
getNodeId={(node) => node.id}
getNodeLabel={(node) => node.label}
getNodeChildren={(node) => node.children}
activeId={selectedFolderNodeId}
expandedIds={expandedFolders}
onOpen={openFolderNode}
onToggle={toggleFolderNode}
disabled={busy}
renderNodeContent={(node) => (
<span className="mailbox-tree-node-label">
<span>{node.label}</span>
{node.flags.length > 0 && <small>{node.flags.join(" ")}</small>}
</span>
)}
/>
</div>
</aside>
<section className="file-list-panel mailbox-message-list-panel" aria-label="Mailbox messages">
<div className="file-list-sticky">
<div className="file-manager-toolbar mailbox-toolbar" aria-label="Mail actions">
<label className="mailbox-profile-field">
<span>Profile</span>
<select value={selectedProfileId} disabled={busy || imapProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
{imapProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
</select>
</label>
<span className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "No IMAP profile selected"}</span>
<Button onClick={() => void loadProfiles()} disabled={busy}>
<RefreshCw size={16} aria-hidden="true" />
Reload
</Button>
</div>
<nav className="file-breadcrumbs" aria-label="Current mailbox folder">
<span className="file-breadcrumb mailbox-breadcrumb-static"><Home size={15} aria-hidden="true" /> {selectedProfile?.name || "Mail"}</span>
<span className="file-breadcrumb-segment"><ChevronRight size={14} aria-hidden="true" /><span className="file-breadcrumb mailbox-breadcrumb-static">{selectedFolder}</span></span>
</nav>
<div className="file-list-meta">
<span>{messages.length} message{messages.length === 1 ? "" : "s"}</span>
<span>{selectedFolder}</span>
{busy && <span>Working...</span>}
</div>
<div className="file-list-table-head mailbox-message-head" role="row">
<span>Subject</span>
<span>Date</span>
<span>Size</span>
</div>
</div>
<div className="file-list-drop-target mailbox-message-scroll">
<div className="file-list-table mailbox-message-table" role="table" aria-label="Mailbox messages">
{messages.length === 0 && <div className="file-list-empty">No messages in this folder.</div>}
{messages.map((message) => {
const selected = message.uid === selectedMessage?.uid;
return (
<div
key={message.uid}
className={`file-list-row file-row mailbox-message-row ${selected ? "is-selected" : ""}`}
role="row"
tabIndex={0}
onClick={() => void openMessage(message)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
void openMessage(message);
}
}}
>
<div className="file-list-name-cell">
<div className="file-list-name">
<Mail className="file-row-icon" size={20} aria-hidden="true" />
<span>
<strong>{message.subject || "(no subject)"}</strong>
<small>{message.from_header || "-"}</small>
</span>
</div>
</div>
<span className="mailbox-message-date">{formatDateTime(message.date, { fallback: "-" })}</span>
<span className="file-row-tail mailbox-message-tail">
{message.attachment_count ? <span><Paperclip size={14} aria-hidden="true" /> {message.attachment_count}</span> : null}
<span>{formatBytes(message.size_bytes)}</span>
</span>
</div>
);
})}
</div>
</div>
</section>
<section className="mailbox-preview-panel" aria-label="Message preview">
<div className="file-tree-heading">Preview</div>
<div className="mailbox-preview-scroll">
<MessageDisplayPanel
title={selectedMessage?.subject}
fields={selectedMessage ? [
{ label: "From", value: selectedMessage.from_header || "-" },
{ label: "To", value: selectedMessage.to_header || "-" },
{ label: "Cc", value: selectedMessage.cc_header || null },
{ label: "Date", value: formatDateTime(selectedMessage.date, { fallback: "-" }) }
] : []}
bodyText={selectedMessage?.body_text}
bodyHtml={selectedMessage?.body_html}
bodyPreview={selectedMessage?.body_preview}
headers={selectedMessage?.headers}
attachments={selectedMessage?.attachments?.map((attachment) => ({
filename: attachment.filename,
contentType: attachment.content_type,
sizeBytes: attachment.size_bytes
}))}
emptyText="Select a message to inspect its content."
/>
</div>
</section>
{busy && (
<div className="file-manager-loading-overlay" aria-live="polite" aria-busy="true">
<div className="loading-frame-panel"><LoadingIndicator label={loadingLabel} /> <span>{loadingLabel}</span></div>
</div>
)}
</div>
</div>
);
}
function buildMailboxFolderTree(folders: MailImapFolderResponse[]): MailFolderNode[] {
const roots: MailFolderNode[] = [];
const byId = new Map<string, MailFolderNode>();
function ensureNode(parts: string[], index: number): MailFolderNode | null {
if (index < 0 || index >= parts.length) return null;
const id = parts.slice(0, index + 1).join("/");
const existing = byId.get(id);
if (existing) return existing;
const node: MailFolderNode = { id, label: parts[index], folderName: null, flags: [], children: [] };
byId.set(id, node);
if (index === 0) {
roots.push(node);
} else {
ensureNode(parts, index - 1)?.children.push(node);
}
return node;
}
for (const folder of folders) {
const parts = folderParts(folder.name);
const node = ensureNode(parts, parts.length - 1);
if (node) {
node.folderName = folder.name;
node.flags = folder.flags ?? [];
}
}
sortFolderNodes(roots);
return roots;
}
function folderParts(folderName: string): string[] {
const normalized = folderName.trim();
if (!normalized) return [];
if (normalized.includes("/")) return normalized.split("/").filter(Boolean);
if (/^INBOX[.]/i.test(normalized)) return ["INBOX", ...normalized.slice(6).split(".").filter(Boolean)];
return [normalized];
}
function sortFolderNodes(nodes: MailFolderNode[]) {
nodes.sort((left, right) => folderSortKey(left.label).localeCompare(folderSortKey(right.label)));
nodes.forEach((node) => sortFolderNodes(node.children));
}
function folderSortKey(label: string): string {
return label.toUpperCase() === "INBOX" ? "0" : `1:${label.toLocaleLowerCase()}`;
}
function findFolderNodeId(nodes: MailFolderNode[], folderName: string): string | null {
for (const node of nodes) {
if (node.folderName === folderName) return node.id;
const child = findFolderNodeId(node.children, folderName);
if (child) return child;
}
return null;
}
function folderAncestorIds(nodes: MailFolderNode[], folderName: string, parents: string[] = []): string[] {
for (const node of nodes) {
const path = [...parents, node.id];
if (node.folderName === folderName) return path;
const child = folderAncestorIds(node.children, folderName, path);
if (child.length > 0) return child;
}
return [];
}
function transportLabel(profile: MailServerProfile): string {
const imap = profile.imap;
if (!imap?.host) return "IMAP not configured";
return `${imap.host}:${imap.port ?? "?"} ${imap.security ?? ""}`.trim();
}
function formatBytes(value?: number | null): string {
if (!value) return "-";
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View File

@@ -5,3 +5,4 @@ export * from "./api/mail";
export { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement"; export { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement";
export type { MailProfileTargetOption } from "./features/mail/MailProfileManagement"; export type { MailProfileTargetOption } from "./features/mail/MailProfileManagement";
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui"; export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";
export { default as MailboxPage } from "./features/mail/MailboxPage";

View File

@@ -1,10 +1,18 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui"; import type { PlatformWebModule } from "@govoplan/core-webui";
const MailboxPage = lazy(() => import("./features/mail/MailboxPage"));
const mailboxRead = ["mail:mailbox:read"];
export const mailModule: PlatformWebModule = { export const mailModule: PlatformWebModule = {
id: "mail", id: "mail",
label: "Mail", label: "Mail",
version: "1.0.0", version: "1.0.0",
dependencies: ["access"] dependencies: ["access"],
navItems: [{ to: "/mail", label: "Mail", iconName: "mail", anyOf: mailboxRead, order: 50 }],
routes: [
{ path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings }) => createElement(MailboxPage, { settings }) }
]
}; };
export default mailModule; export default mailModule;

View File

@@ -193,3 +193,397 @@
flex-direction: column; flex-direction: column;
} }
} }
.mailbox-page {
display: grid;
gap: 18px;
}
.mailbox-toolbar,
.mailbox-message-toolbar {
display: flex;
align-items: center;
gap: 12px;
justify-content: space-between;
}
.mailbox-toolbar label {
display: grid;
gap: 5px;
min-width: min(340px, 100%);
}
.mailbox-toolbar label span,
.mailbox-preview-header span {
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.mailbox-toolbar-meta {
flex: 1;
min-width: 0;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mailbox-layout {
display: grid;
grid-template-columns: minmax(190px, 240px) minmax(0, 1.25fr) minmax(320px, .75fr);
gap: 18px;
align-items: start;
}
.mailbox-folder-list {
display: grid;
gap: 6px;
}
.mailbox-folder-list button {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
gap: 8px;
align-items: center;
width: 100%;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
padding: 9px 10px;
text-align: left;
cursor: pointer;
}
.mailbox-folder-list button:hover,
.mailbox-folder-list button:focus-visible,
.mailbox-folder-list button.is-active {
border-color: var(--accent);
background: var(--surface-subtle);
}
.mailbox-folder-list small {
grid-column: 2;
min-width: 0;
color: var(--muted);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
}
.mailbox-message-table {
min-width: 0;
}
.mailbox-message-table .data-grid-cell.is-selected {
background: var(--surface-subtle);
}
.mailbox-subject-button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
min-width: 0;
border: 0;
background: transparent;
color: var(--text-strong);
font: inherit;
font-weight: 700;
text-align: left;
cursor: pointer;
}
.mailbox-subject-button span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mailbox-attachment-count {
display: inline-flex;
align-items: center;
gap: 4px;
}
.mailbox-preview {
display: grid;
gap: 14px;
min-width: 0;
}
.mailbox-preview-header {
display: grid;
gap: 8px;
}
.mailbox-preview-header h3 {
margin: 0;
color: var(--text-strong);
overflow-wrap: anywhere;
}
.mailbox-preview-header div {
display: grid;
grid-template-columns: 70px minmax(0, 1fr);
gap: 10px;
}
.mailbox-preview-header strong {
min-width: 0;
overflow-wrap: anywhere;
}
.mailbox-message-body {
max-height: 420px;
margin: 0;
padding: 12px;
overflow: auto;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface-subtle);
color: var(--text);
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.mailbox-attachments {
display: grid;
gap: 8px;
}
.mailbox-attachments h4 {
margin: 0;
}
.mailbox-attachments div {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
.mailbox-attachments small {
color: var(--muted);
}
.mailbox-headers dl {
display: grid;
gap: 7px;
max-height: 280px;
overflow: auto;
}
.mailbox-headers dl div {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
gap: 10px;
}
.mailbox-headers dt {
color: var(--muted);
font-weight: 700;
}
.mailbox-headers dd {
margin: 0;
overflow-wrap: anywhere;
}
@media (max-width: 1200px) {
.mailbox-layout {
grid-template-columns: 220px minmax(0, 1fr);
}
.mailbox-layout > .card:last-child {
grid-column: 1 / -1;
}
}
@media (max-width: 760px) {
.mailbox-toolbar,
.mailbox-message-toolbar {
align-items: stretch;
flex-direction: column;
}
.mailbox-layout {
grid-template-columns: 1fr;
}
}
/* Mailbox work surface: shared explorer/list pattern with mail-specific columns. */
.mailbox-page.file-manager-fullscreen {
position: relative;
display: grid;
grid-template-rows: 1fr;
height: calc(100vh - 115px);
padding: 0;
overflow: hidden;
}
.mailbox-shell.file-manager-shell {
position: relative;
grid-template-columns: minmax(230px, 290px) minmax(390px, 1fr) minmax(340px, .82fr);
}
.mailbox-shell.is-loading .mailbox-preview-panel {
filter: blur(1.5px);
pointer-events: none;
user-select: none;
}
.mailbox-tree-empty {
padding: 8px 10px;
}
.mailbox-tree-node-label {
display: grid;
gap: 2px;
min-width: 0;
}
.mailbox-tree-node-label > span,
.mailbox-tree-node-label small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mailbox-tree-node-label small {
color: var(--muted);
font-size: 11px;
}
.mailbox-toolbar.file-manager-toolbar {
display: grid;
grid-template-columns: minmax(180px, 280px) minmax(0, 1fr) auto;
gap: 12px;
}
.mailbox-profile-field {
display: grid;
gap: 4px;
min-width: 0;
}
.mailbox-profile-field > span {
color: var(--muted);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.mailbox-toolbar-meta {
min-width: 0;
overflow: hidden;
color: var(--muted);
text-overflow: ellipsis;
white-space: nowrap;
}
.mailbox-breadcrumb-static {
cursor: default;
}
.mailbox-message-list-panel {
border-right: 1px solid var(--line);
}
.mailbox-message-table {
min-width: 660px;
}
.mailbox-message-head,
.mailbox-message-row {
grid-template-columns: minmax(260px, 1fr) 190px 130px;
}
.mailbox-message-head span {
justify-self: start;
}
.mailbox-message-row {
cursor: pointer;
}
.mailbox-message-row .file-list-name strong {
max-width: 100%;
}
.mailbox-message-date {
min-width: 0;
overflow: hidden;
color: var(--muted);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.mailbox-message-tail {
justify-content: flex-end;
}
.mailbox-message-tail > span {
display: inline-flex;
align-items: center;
gap: 4px;
}
.mailbox-preview-panel {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
background: var(--panel);
}
.mailbox-preview-scroll {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 18px;
}
@media (max-width: 1250px) {
.mailbox-shell.file-manager-shell {
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
}
.mailbox-preview-panel {
grid-column: 1 / -1;
min-height: 320px;
border-top: 1px solid var(--line);
}
}
@media (max-width: 760px) {
.mailbox-toolbar.file-manager-toolbar {
grid-template-columns: 1fr;
}
.mailbox-message-head {
display: none;
}
.mailbox-message-table {
min-width: 0;
}
.mailbox-message-row {
grid-template-columns: 1fr;
gap: 8px;
}
.mailbox-message-tail {
justify-content: flex-start;
}
}