initial commit after split

This commit is contained in:
2026-06-24 01:43:21 +02:00
parent 23213b15e2
commit d922b7701c
47 changed files with 4432 additions and 0 deletions

View File

@@ -1,2 +1,16 @@
# govoplan-mail
GovOPlaN Mail module.
This repository owns the Mail module manifest, backend router, mail schemas, mail profile ORM model, SMTP/IMAP profile services, SMTP/IMAP adapters, development mailbox backend, and mail profile WebUI package. The remaining `app.*` imports are transitional core adapters for auth, governance settings, secrets, and legacy access models.
## Development
Install through the core development environment:
```bash
cd /mnt/DATA/git/govoplan-core
./.venv/bin/python -m pip install -r requirements-dev.txt
```
The backend module is registered through the `govoplan.modules` entry point `mail`. The frontend package is `@govoplan/mail-webui`.

27
pyproject.toml Normal file
View File

@@ -0,0 +1,27 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-mail"
version = "0.1.0"
description = "GovOPlaN mail module with backend and WebUI integration."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.0",
"pydantic>=2,<3",
"redis>=5,<6",
"SQLAlchemy>=2,<3",
]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
govoplan_mail = ["py.typed"]
[project.entry-points."govoplan.modules"]
mail = "govoplan_mail.backend.manifest:get_manifest"

View File

@@ -0,0 +1 @@
"""govoplan-mail module package."""

View File

@@ -0,0 +1 @@
"""Backend integration for this GovOPlaN module."""

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field
class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
class TransportSecurity(StrEnum):
PLAIN = "plain"
TLS = "tls"
STARTTLS = "starttls"
class SmtpConfig(StrictModel):
host: str | None = None
port: int | None = Field(default=None, ge=1, le=65535)
username: str | None = None
password: str | None = None
security: TransportSecurity = TransportSecurity.STARTTLS
timeout_seconds: int = Field(default=30, ge=1)
class ImapConfig(StrictModel):
enabled: bool = False
host: str | None = None
port: int | None = Field(default=None, ge=1, le=65535)
username: str | None = None
password: str | None = None
security: TransportSecurity = TransportSecurity.TLS
sent_folder: str = "auto"
timeout_seconds: int = Field(default=30, ge=1)

View File

View File

@@ -0,0 +1,40 @@
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import Boolean, ForeignKey, Index, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class MailServerProfile(Base, TimestampMixin):
__tablename__ = "mail_server_profiles"
__table_args__ = (
UniqueConstraint("tenant_id", "slug", name="uq_mail_server_profiles_tenant_slug"),
Index("ix_mail_server_profiles_scope", "scope_type", "scope_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True)
scope_type: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False, index=True)
scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
smtp_config: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
smtp_password_encrypted: Mapped[str | None] = mapped_column(Text)
imap_config: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
imap_password_encrypted: Mapped[str | None] = mapped_column(Text)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
tenant: Mapped["Tenant"] = relationship()

View File

@@ -0,0 +1,327 @@
from __future__ import annotations
import json
import re
import shutil
from dataclasses import dataclass
from datetime import datetime, timezone
from email import policy
from email.message import EmailMessage
from email.parser import BytesParser
from pathlib import Path
from typing import Any
from uuid import uuid4
from govoplan_mail.backend.runtime import settings
MOCK_SMTP_HOSTS = {"mock", "mock.smtp", "mock.smtp.local", "mock-mail", "__mock_smtp__"}
MOCK_IMAP_HOSTS = {"mock", "mock.imap", "mock.imap.local", "mock-mail", "__mock_imap__"}
MOCK_IMAP_FOLDERS = [
{"name": "INBOX", "flags": []},
{"name": "Sent", "flags": ["\\Sent"]},
{"name": "Drafts", "flags": ["\\Drafts"]},
{"name": "Trash", "flags": ["\\Trash"]},
{"name": "Archive", "flags": ["\\Archive"]},
]
@dataclass(frozen=True, slots=True)
class MockDeliveryRecord:
id: str
kind: str
created_at: str
envelope_from: str | None
envelope_recipients: list[str]
subject: str | None
from_header: str | None
to_header: str | None
cc_header: str | None
bcc_header: str | None
message_id: str | None
size_bytes: int
body_preview: str | None
attachment_count: int
folder: str | None = None
smtp_host: str | None = None
imap_host: str | None = None
raw_filename: str | None = None
headers: dict[str, str] | None = None
attachments: list[dict[str, Any]] | None = None
def as_dict(self, *, include_raw: bool = False) -> dict[str, Any]:
data = {
"id": self.id,
"kind": self.kind,
"created_at": self.created_at,
"envelope_from": self.envelope_from,
"envelope_recipients": self.envelope_recipients,
"subject": self.subject,
"from_header": self.from_header,
"to_header": self.to_header,
"cc_header": self.cc_header,
"bcc_header": self.bcc_header,
"message_id": self.message_id,
"size_bytes": self.size_bytes,
"body_preview": self.body_preview,
"attachment_count": self.attachment_count,
"folder": self.folder,
"smtp_host": self.smtp_host,
"imap_host": self.imap_host,
"raw_filename": self.raw_filename,
"headers": self.headers or {},
"attachments": self.attachments or [],
}
if include_raw:
data["raw_eml"] = read_raw_message(self.id)
return data
def _base_dir() -> Path:
path = Path(settings.mock_mailbox_dir)
path.mkdir(parents=True, exist_ok=True)
(path / "messages").mkdir(parents=True, exist_ok=True)
return path
def _message_dir() -> Path:
return _base_dir() / "messages"
def _failure_path() -> Path:
return _base_dir() / "failures.json"
def normalize_mock_host(host: str | None) -> str:
return (host or "").strip().lower()
def is_mock_smtp_host(host: str | None) -> bool:
return normalize_mock_host(host) in MOCK_SMTP_HOSTS
def is_mock_imap_host(host: str | None) -> bool:
return normalize_mock_host(host) in MOCK_IMAP_HOSTS
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _message_to_bytes(message: EmailMessage | bytes) -> bytes:
if isinstance(message, bytes):
return message
return message.as_bytes(policy=policy.SMTP)
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_preview(message: EmailMessage) -> str | None:
body = None
try:
if message.is_multipart():
body = message.get_body(preferencelist=("plain", "html"))
else:
body = message
if body is None:
return None
content = body.get_content()
except Exception:
return None
text = re.sub(r"\s+", " ", str(content)).strip()
if not text:
return None
return text[:600]
def _attachment_summaries(message: EmailMessage) -> list[dict[str, Any]]:
attachments: list[dict[str, Any]] = []
for part in message.iter_attachments():
payload = part.get_payload(decode=True) or b""
attachments.append(
{
"filename": part.get_filename(),
"content_type": part.get_content_type(),
"size_bytes": len(payload),
}
)
return attachments
def _headers(message: EmailMessage) -> dict[str, str]:
return {str(key): str(value) for key, value in message.items()}
def _record_from_raw(
raw: bytes,
*,
kind: str,
envelope_from: str | None = None,
envelope_recipients: list[str] | None = None,
folder: str | None = None,
smtp_host: str | None = None,
imap_host: str | None = None,
) -> MockDeliveryRecord:
message_id = uuid4().hex
raw_filename = f"{message_id}.eml"
json_filename = f"{message_id}.json"
message = _parse_message(raw)
attachments = _attachment_summaries(message)
record = MockDeliveryRecord(
id=message_id,
kind=kind,
created_at=_now_iso(),
envelope_from=envelope_from,
envelope_recipients=list(envelope_recipients or []),
subject=_header_text(message, "Subject"),
from_header=_header_text(message, "From"),
to_header=_header_text(message, "To"),
cc_header=_header_text(message, "Cc"),
bcc_header=_header_text(message, "Bcc"),
message_id=_header_text(message, "Message-ID"),
size_bytes=len(raw),
body_preview=_body_preview(message),
attachment_count=len(attachments),
folder=folder,
smtp_host=smtp_host,
imap_host=imap_host,
raw_filename=raw_filename,
headers=_headers(message),
attachments=attachments,
)
message_dir = _message_dir()
(message_dir / raw_filename).write_bytes(raw)
(message_dir / json_filename).write_text(json.dumps(record.as_dict(), indent=2, ensure_ascii=False), encoding="utf-8")
return record
def record_smtp_delivery(
message: EmailMessage | bytes,
*,
envelope_from: str,
envelope_recipients: list[str],
smtp_host: str | None = None,
) -> MockDeliveryRecord:
return _record_from_raw(
_message_to_bytes(message),
kind="smtp",
envelope_from=envelope_from,
envelope_recipients=envelope_recipients,
smtp_host=smtp_host,
)
def record_imap_append(
message_bytes: bytes,
*,
folder: str,
imap_host: str | None = None,
) -> MockDeliveryRecord:
return _record_from_raw(message_bytes, kind="imap_append", folder=folder, imap_host=imap_host)
def _load_record(record_id: str) -> dict[str, Any] | None:
path = _message_dir() / f"{record_id}.json"
if not path.exists():
return None
try:
return json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return None
def list_records(*, kind: str | None = None, limit: int = 100) -> list[dict[str, Any]]:
records: list[dict[str, Any]] = []
for path in _message_dir().glob("*.json"):
try:
record = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
continue
if kind and record.get("kind") != kind:
continue
records.append(record)
records.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True)
return records[: max(1, min(limit, 500))]
def get_record(record_id: str, *, include_raw: bool = True) -> dict[str, Any] | None:
record = _load_record(record_id)
if record and include_raw:
record["raw_eml"] = read_raw_message(record_id)
return record
def read_raw_message(record_id: str) -> str | None:
record = _load_record(record_id)
if not record:
return None
raw_filename = record.get("raw_filename")
if not raw_filename:
return None
raw_path = _message_dir() / str(raw_filename)
if not raw_path.exists():
return None
return raw_path.read_text(encoding="utf-8", errors="replace")
def clear_records() -> int:
message_dir = _message_dir()
count = len(list(message_dir.glob("*.json")))
if message_dir.exists():
shutil.rmtree(message_dir)
message_dir.mkdir(parents=True, exist_ok=True)
return count
def get_failures() -> dict[str, Any]:
path = _failure_path()
if not path.exists():
return {
"fail_next_smtp": False,
"fail_next_imap": False,
"smtp_reject_recipients_containing": None,
}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
data = {}
return {
"fail_next_smtp": bool(data.get("fail_next_smtp")),
"fail_next_imap": bool(data.get("fail_next_imap")),
"smtp_reject_recipients_containing": data.get("smtp_reject_recipients_containing") or None,
}
def set_failures(*, fail_next_smtp: bool | None = None, fail_next_imap: bool | None = None, smtp_reject_recipients_containing: str | None = None) -> dict[str, Any]:
current = get_failures()
if fail_next_smtp is not None:
current["fail_next_smtp"] = fail_next_smtp
if fail_next_imap is not None:
current["fail_next_imap"] = fail_next_imap
current["smtp_reject_recipients_containing"] = smtp_reject_recipients_containing or None
_failure_path().write_text(json.dumps(current, indent=2), encoding="utf-8")
return current
def consume_fail_next_smtp() -> bool:
current = get_failures()
if current.get("fail_next_smtp"):
current["fail_next_smtp"] = False
_failure_path().write_text(json.dumps(current, indent=2), encoding="utf-8")
return True
return False
def consume_fail_next_imap() -> bool:
current = get_failures()
if current.get("fail_next_imap"):
current["fail_next_imap"] = False
_failure_path().write_text(json.dumps(current, indent=2), encoding="utf-8")
return True
return False

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from govoplan_core.auth.dependencies import ApiPrincipal, require_scope
from govoplan_mail.backend.dev.mock_mailbox import (
clear_records,
get_failures,
get_record,
list_records,
set_failures,
)
router = APIRouter(prefix="/dev/mailbox", tags=["dev-mailbox"])
class MockMailboxListResponse(BaseModel):
messages: list[dict[str, Any]]
class MockMailboxMessageResponse(BaseModel):
message: dict[str, Any]
class MockMailboxClearResponse(BaseModel):
deleted_count: int
class MockMailboxFailureConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
fail_next_smtp: bool | None = None
fail_next_imap: bool | None = None
smtp_reject_recipients_containing: str | None = Field(default=None, max_length=255)
class MockMailboxFailureResponse(BaseModel):
config: dict[str, Any]
@router.get("/messages", response_model=MockMailboxListResponse)
def list_mock_mailbox_messages(
kind: str | None = None,
limit: int = 100,
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
):
"""List messages captured by the integrated development mail sandbox."""
del principal
return MockMailboxListResponse(messages=list_records(kind=kind, limit=limit))
@router.get("/messages/{message_id}", response_model=MockMailboxMessageResponse)
def get_mock_mailbox_message(
message_id: str,
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
):
del principal
message = get_record(message_id, include_raw=True)
if not message:
raise HTTPException(status_code=404, detail="Mock mailbox message not found")
return MockMailboxMessageResponse(message=message)
@router.delete("/messages", response_model=MockMailboxClearResponse)
def clear_mock_mailbox_messages(
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send_test")),
):
del principal
return MockMailboxClearResponse(deleted_count=clear_records())
@router.get("/failures", response_model=MockMailboxFailureResponse)
def get_mock_failure_config(
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
):
del principal
return MockMailboxFailureResponse(config=get_failures())
@router.post("/failures", response_model=MockMailboxFailureResponse)
def update_mock_failure_config(
payload: MockMailboxFailureConfig,
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send_test")),
):
del principal
config = set_failures(
fail_next_smtp=payload.fail_next_smtp,
fail_next_imap=payload.fail_next_imap,
smtp_reject_recipients_containing=payload.smtp_reject_recipients_containing,
)
return MockMailboxFailureResponse(config=config)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,83 @@
from __future__ import annotations
from pathlib import Path
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, 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
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category="Mail",
level="tenant",
module_id=module_id,
resource=resource,
action=action,
)
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: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."),
)
ROLE_TEMPLATES = (
RoleTemplate(
slug="mail_profile_admin",
name="Mail profile administrator",
description="Manage reusable mail profiles and credentials.",
permissions=(
"mail:profile:read",
"mail:profile:use",
"mail:profile:test",
"mail:profile:write",
"mail:secret:manage",
),
),
RoleTemplate(
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"),
),
)
def _mail_router(context: ModuleContext):
from govoplan_mail.backend.runtime import configure_runtime
configure_runtime(settings=context.settings)
from govoplan_mail.backend.router import router
return router
manifest = ModuleManifest(
id="mail",
name="Mail",
version="1.0.0",
dependencies=("access",),
optional_dependencies=(),
permissions=PERMISSIONS,
route_factory=_mail_router,
role_templates=ROLE_TEMPLATES,
frontend=FrontendModule(module_id="mail", package_name="@govoplan/mail-webui"),
migration_spec=MigrationSpec(
module_id="mail",
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
),
)
def get_manifest() -> ModuleManifest:
return manifest

View File

@@ -0,0 +1,420 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from govoplan_mail.backend.schemas import (
MailConnectionTestResponse,
MailImapFolderListResponse,
MailImapFolderResponse,
MailImapTestRequest,
MailProfilePolicyResponse,
MailProfilePolicyUpdateRequest,
MailServerProfileCreateRequest,
MailServerProfileListResponse,
MailServerProfileResponse,
MailServerProfileUpdateRequest,
MailSmtpTestRequest,
)
from govoplan_core.auth.dependencies import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.db.session import get_session
from govoplan_mail.backend.mail_profiles import (
MailProfileError,
create_mail_server_profile,
effective_mail_profile_policy,
get_mail_profile_policy,
get_mail_server_profile,
imap_config_from_profile,
list_mail_server_profiles,
profile_response_payload,
set_mail_profile_policy,
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.smtp import test_smtp_login
from sqlalchemy.orm import Session
router = APIRouter(prefix="/mail", tags=["mail"])
def _require_scope(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None:
if not any(has_scope(principal, scope) for scope in scopes):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Requires one of: " + ", ".join(scopes))
def _require_profile_write_scope(principal: ApiPrincipal, scope_type: str) -> None:
if scope_type == "system":
_require_scope(principal, "system:settings:write")
else:
_require_scope(principal, "mail:profile:write")
def _require_profile_credentials_scope(principal: ApiPrincipal, scope_type: str) -> None:
if scope_type == "system":
_require_scope(principal, "system:settings:write")
else:
_require_scope(principal, "mail:secret:manage")
def _require_policy_read_scope(principal: ApiPrincipal, scope_type: str) -> None:
if scope_type == "system":
_require_scope(principal, "system:settings:read")
elif scope_type == "tenant":
_require_any_scope(principal, "admin:policies:read", "mail:profile:read")
else:
_require_any_scope(principal, "admin:policies:read", "mail:profile:read", "campaigns:campaign:read")
def _require_policy_write_scope(principal: ApiPrincipal, scope_type: str) -> None:
if scope_type == "system":
_require_scope(principal, "system:settings:write")
elif scope_type == "tenant":
_require_scope(principal, "admin:policies:write")
else:
_require_any_scope(principal, "admin:policies:write", "mail:profile:write")
def _profile_response(profile) -> MailServerProfileResponse:
return MailServerProfileResponse.model_validate(profile_response_payload(profile))
@router.get("/profiles", response_model=MailServerProfileListResponse)
def list_profiles(
include_inactive: bool = False,
campaign_id: str | None = Query(default=None),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_any_scope(principal, "mail:profile:read", "system:settings:read")
try:
profiles = list_mail_server_profiles(
session,
tenant_id=principal.tenant_id,
include_inactive=include_inactive,
campaign_id=campaign_id,
)
return MailServerProfileListResponse(profiles=[_profile_response(profile) for profile in profiles])
except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.post("/profiles", response_model=MailServerProfileResponse, status_code=status.HTTP_201_CREATED)
def create_profile(
payload: MailServerProfileCreateRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_profile_write_scope(principal, payload.scope_type)
if payload.smtp.password or (payload.imap and payload.imap.password):
_require_profile_credentials_scope(principal, payload.scope_type)
try:
profile = create_mail_server_profile(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
name=payload.name,
slug=payload.slug,
description=payload.description,
smtp=payload.smtp,
imap=payload.imap,
is_active=payload.is_active,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
)
session.commit()
session.refresh(profile)
return _profile_response(profile)
except MailProfileError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
@router.get("/profiles/{profile_id}", response_model=MailServerProfileResponse)
def get_profile(
profile_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_any_scope(principal, "mail:profile:read", "system:settings:read")
try:
return _profile_response(get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id))
except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.patch("/profiles/{profile_id}", response_model=MailServerProfileResponse)
def update_profile(
profile_id: str,
payload: MailServerProfileUpdateRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
try:
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
_require_profile_write_scope(principal, profile.scope_type or "tenant")
if (payload.smtp and "password" in payload.smtp.model_fields_set) or (payload.imap and "password" in payload.imap.model_fields_set) or payload.clear_imap:
_require_profile_credentials_scope(principal, profile.scope_type or "tenant")
update_mail_server_profile(
session,
profile,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
name=payload.name,
slug=payload.slug,
description=payload.description if "description" in payload.model_fields_set else None,
is_active=payload.is_active,
smtp=payload.smtp,
imap=payload.imap,
clear_imap=payload.clear_imap,
)
session.commit()
session.refresh(profile)
return _profile_response(profile)
except MailProfileError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
@router.delete("/profiles/{profile_id}", response_model=MailServerProfileResponse)
def deactivate_profile(
profile_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
try:
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
_require_profile_write_scope(principal, profile.scope_type or "tenant")
profile.is_active = False
session.add(profile)
session.commit()
session.refresh(profile)
return _profile_response(profile)
except MailProfileError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.get("/policies/{scope_type}", response_model=MailProfilePolicyResponse)
def read_mail_profile_policy(
scope_type: str,
scope_id: str | None = Query(default=None),
campaign_id: str | None = Query(default=None),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
scope_type = scope_type.strip().casefold()
_require_policy_read_scope(principal, scope_type)
try:
policy = get_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id)
effective = None
if campaign_id:
effective = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=campaign_id).as_dict()
elif scope_type == "campaign" and scope_id:
effective = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=scope_id).as_dict()
return MailProfilePolicyResponse(scope_type=scope_type, scope_id=scope_id, policy=policy, effective_policy=effective)
except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.put("/policies/{scope_type}", response_model=MailProfilePolicyResponse)
def write_mail_profile_policy(
scope_type: str,
payload: MailProfilePolicyUpdateRequest,
scope_id: str | None = Query(default=None),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
scope_type = scope_type.strip().casefold()
_require_policy_write_scope(principal, scope_type)
try:
policy = set_mail_profile_policy(
session,
tenant_id=principal.tenant_id,
scope_type=scope_type,
scope_id=scope_id,
policy=payload.policy.model_dump(mode="json"),
)
session.commit()
effective = None
if scope_type == "campaign" and scope_id:
effective = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=scope_id).as_dict()
return MailProfilePolicyResponse(scope_type=scope_type, scope_id=scope_id, policy=policy, effective_policy=effective)
except MailProfileError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
@router.post("/profiles/{profile_id}/test-smtp", response_model=MailConnectionTestResponse)
def test_profile_smtp(
profile_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "mail:profile:test")
_require_scope(principal, "mail:profile:use")
try:
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id, require_active=True)
smtp = smtp_config_from_profile(profile)
result = test_smtp_login(smtp_config=smtp)
return MailConnectionTestResponse(ok=True, protocol="smtp", host=result.host, port=result.port, security=result.security, message="SMTP connection successful.", details={"authenticated": result.authenticated})
except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except Exception as exc:
return MailConnectionTestResponse(ok=False, protocol="smtp", message=_safe_error_message(exc), details={"error_type": exc.__class__.__name__})
@router.post("/profiles/{profile_id}/test-imap", response_model=MailConnectionTestResponse)
def test_profile_imap(
profile_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "mail:profile:test")
_require_scope(principal, "mail:profile:use")
try:
profile = get_mail_server_profile(session, tenant_id=principal.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")
result = test_imap_login(imap_config=imap)
return MailConnectionTestResponse(ok=True, protocol="imap", host=result.host, port=result.port, security=result.security, message="IMAP connection successful.", details={"authenticated": result.authenticated})
except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except Exception as exc:
return MailConnectionTestResponse(ok=False, protocol="imap", message=_safe_error_message(exc), details={"error_type": exc.__class__.__name__})
@router.post("/profiles/{profile_id}/list-imap-folders", response_model=MailImapFolderListResponse)
def list_profile_imap_folders(
profile_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "mail:profile:test")
_require_scope(principal, "mail:profile:use")
try:
profile = get_mail_server_profile(session, tenant_id=principal.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")
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 Exception as exc:
return MailImapFolderListResponse(ok=False, message=_safe_error_message(exc), folders=[], detected_sent_folder=None, details={"error_type": exc.__class__.__name__})
def _safe_error_message(exc: Exception) -> str:
text = str(exc).strip()
return text or exc.__class__.__name__
@router.post("/test-smtp", response_model=MailConnectionTestResponse)
def test_smtp_settings(
payload: MailSmtpTestRequest,
principal: ApiPrincipal = Depends(get_api_principal),
):
"""Test SMTP connectivity/login without sending any message."""
if not has_scope(principal, "mail:profile:test"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: mail_servers:test")
if not has_scope(principal, "mail:secret:manage"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Testing raw credentials requires mail_servers:manage_credentials")
try:
result = test_smtp_login(smtp_config=payload)
return MailConnectionTestResponse(
ok=True,
protocol="smtp",
host=result.host,
port=result.port,
security=result.security,
message="SMTP connection successful.",
details={"authenticated": result.authenticated},
)
except Exception as exc:
return MailConnectionTestResponse(
ok=False,
protocol="smtp",
host=payload.host,
port=payload.port,
security=payload.security.value,
message=_safe_error_message(exc),
details={"error_type": exc.__class__.__name__},
)
@router.post("/test-imap", response_model=MailConnectionTestResponse)
def test_imap_settings(
payload: MailImapTestRequest,
principal: ApiPrincipal = Depends(get_api_principal),
):
"""Test IMAP connectivity/login without selecting or appending messages."""
if not has_scope(principal, "mail:profile:test"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: mail_servers:test")
if not has_scope(principal, "mail:secret:manage"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Testing raw credentials requires mail_servers:manage_credentials")
try:
result = test_imap_login(imap_config=payload)
return MailConnectionTestResponse(
ok=True,
protocol="imap",
host=result.host,
port=result.port,
security=result.security,
message="IMAP connection successful.",
details={"authenticated": result.authenticated},
)
except Exception as exc:
return MailConnectionTestResponse(
ok=False,
protocol="imap",
host=payload.host,
port=payload.port,
security=payload.security.value,
message=_safe_error_message(exc),
details={"error_type": exc.__class__.__name__},
)
@router.post("/list-imap-folders", response_model=MailImapFolderListResponse)
def list_imap_folder_settings(
payload: MailImapTestRequest,
principal: ApiPrincipal = Depends(get_api_principal),
):
"""List visible IMAP folders and return the best Sent-folder guess."""
if not has_scope(principal, "mail:profile:test"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: mail_servers:test")
if not has_scope(principal, "mail:secret:manage"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Testing raw credentials requires mail_servers:manage_credentials")
try:
result = list_imap_folders(imap_config=payload)
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 Exception as exc:
return MailImapFolderListResponse(
ok=False,
host=payload.host,
port=payload.port,
security=payload.security.value,
message=_safe_error_message(exc),
folders=[],
detected_sent_folder=None,
details={"error_type": exc.__class__.__name__},
)

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from typing import Any
_runtime_settings: object | None = None
def configure_runtime(*, settings: object | None = None) -> None:
global _runtime_settings
if settings is not None:
_runtime_settings = settings
def get_settings() -> object:
if _runtime_settings is not None:
return _runtime_settings
try:
from govoplan_core.settings import settings as legacy_settings
except ModuleNotFoundError as exc:
raise RuntimeError("GovOPlaN Mail runtime settings are not configured") from exc
return legacy_settings
class SettingsProxy:
def __getattr__(self, name: str) -> Any:
return getattr(get_settings(), name)
settings = SettingsProxy()

View File

@@ -0,0 +1,127 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
class MailSmtpTestRequest(SmtpConfig):
"""SMTP settings supplied directly from the WebUI mail settings form."""
class MailImapTestRequest(ImapConfig):
"""IMAP settings supplied directly from the WebUI mail settings form."""
enabled: bool = True
MailProfileScope = Literal["system", "tenant", "user", "group", "campaign"]
class MailCredentialPolicyPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
inherit: bool | None = None
allow_override: bool | None = None
class MailProfilePolicyPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
allowed_profile_ids: list[str] = Field(default_factory=list)
allow_user_profiles: bool | None = None
allow_group_profiles: bool | None = None
allow_campaign_profiles: bool | None = None
smtp_credentials: MailCredentialPolicyPayload = Field(default_factory=MailCredentialPolicyPayload)
imap_credentials: MailCredentialPolicyPayload = Field(default_factory=MailCredentialPolicyPayload)
whitelist: dict[str, list[str]] = Field(default_factory=dict)
blacklist: dict[str, list[str]] = Field(default_factory=dict)
class MailProfilePolicyUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
policy: MailProfilePolicyPayload = Field(default_factory=MailProfilePolicyPayload)
class MailProfilePolicyResponse(BaseModel):
scope_type: MailProfileScope
scope_id: str | None = None
policy: dict[str, Any]
effective_policy: dict[str, Any] | None = None
class MailServerProfileCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
slug: str | None = Field(default=None, max_length=100)
description: str | None = None
is_active: bool = True
scope_type: MailProfileScope = "tenant"
scope_id: str | None = None
smtp: SmtpConfig
imap: ImapConfig | None = None
class MailServerProfileUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str | None = Field(default=None, max_length=255)
slug: str | None = Field(default=None, max_length=100)
description: str | None = None
is_active: bool | None = None
smtp: SmtpConfig | None = None
imap: ImapConfig | None = None
clear_imap: bool = False
class MailServerProfileResponse(BaseModel):
id: str
tenant_id: str | None = None
scope_type: MailProfileScope = "tenant"
scope_id: str | None = None
name: str
slug: str
description: str | None = None
is_active: bool
smtp: dict[str, Any]
imap: dict[str, Any] | None = None
smtp_password_configured: bool = False
imap_password_configured: bool = False
created_at: datetime
updated_at: datetime
class MailServerProfileListResponse(BaseModel):
profiles: list[MailServerProfileResponse] = Field(default_factory=list)
class MailConnectionTestResponse(BaseModel):
ok: bool
protocol: Literal["smtp", "imap"]
host: str | None = None
port: int | None = None
security: str | None = None
message: str
details: dict[str, Any] = Field(default_factory=dict)
class MailImapFolderResponse(BaseModel):
name: str
flags: list[str] = Field(default_factory=list)
class MailImapFolderListResponse(BaseModel):
ok: bool
protocol: Literal["imap"] = "imap"
host: str | None = None
port: int | None = None
security: str | None = None
message: str
folders: list[MailImapFolderResponse] = Field(default_factory=list)
detected_sent_folder: str | None = None
details: dict[str, Any] = Field(default_factory=dict)

View File

@@ -0,0 +1,341 @@
from __future__ import annotations
import imaplib
import re
import socket
import ssl
import time
from dataclasses import dataclass
from govoplan_mail.backend.config import ImapConfig, TransportSecurity
from govoplan_mail.backend.dev.mock_mailbox import (
MOCK_IMAP_FOLDERS,
consume_fail_next_imap,
is_mock_imap_host,
record_imap_append,
)
class ImapConfigurationError(ValueError):
"""Raised when IMAP settings are incomplete or inconsistent."""
class ImapAppendError(RuntimeError):
"""Raised when APPENDing to Sent fails.
temporary=True means retrying later may help. temporary=False means the
configuration or mailbox choice probably needs user/admin attention.
"""
def __init__(self, message: str, *, temporary: bool | None = None):
super().__init__(message)
self.temporary = temporary
@dataclass(frozen=True, slots=True)
class ImapLoginTestResult:
host: str
port: int
security: str
authenticated: bool
@dataclass(frozen=True, slots=True)
class ImapMailboxInfo:
name: str
flags: list[str]
@dataclass(frozen=True, slots=True)
class ImapFolderListResult:
host: str
port: int
security: str
folders: list[ImapMailboxInfo]
detected_sent_folder: str | None = None
@dataclass(frozen=True, slots=True)
class ImapAppendResult:
host: str
port: int
security: str
folder: str
bytes_appended: int
response: str | None = None
def _require_imap_config(config: ImapConfig) -> tuple[str, int]:
if not config.enabled:
raise ImapConfigurationError("IMAP is disabled")
if not config.host:
raise ImapConfigurationError("IMAP host is required")
if not config.port:
raise ImapConfigurationError("IMAP port is required")
if bool(config.username) != bool(config.password):
raise ImapConfigurationError("IMAP username and password must be provided together, or both omitted")
return config.host, config.port
def _open_imap(config: ImapConfig) -> imaplib.IMAP4:
host, port = _require_imap_config(config)
context = ssl.create_default_context()
try:
if config.security == TransportSecurity.TLS:
client: imaplib.IMAP4 = imaplib.IMAP4_SSL(host=host, port=port, timeout=config.timeout_seconds, ssl_context=context)
else:
client = imaplib.IMAP4(host=host, port=port, timeout=config.timeout_seconds)
if config.security == TransportSecurity.STARTTLS:
typ, data = client.starttls(ssl_context=context)
if typ != "OK":
raise ImapAppendError(f"IMAP STARTTLS failed: {data!r}", temporary=True)
if config.username and config.password:
typ, data = client.login(config.username, config.password)
if typ != "OK":
raise ImapAppendError(f"IMAP login failed: {data!r}", temporary=False)
return client
except Exception:
try:
client.logout() # type: ignore[possibly-undefined]
except Exception:
pass
raise
def _decode_item(item: bytes | str | None) -> str:
if item is None:
return ""
if isinstance(item, bytes):
return item.decode("utf-8", errors="replace")
return item
def _unquote_imap_token(value: str) -> str:
value = value.strip()
if len(value) >= 2 and value[0] == '"' and value[-1] == '"':
value = value[1:-1]
value = value.replace('\\"', '"').replace('\\\\', '\\')
return value
def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str]] | None:
r"""Best-effort parser for IMAP LIST response lines.
RFC 3501 LIST responses contain attributes, hierarchy delimiter, then mailbox
name. Some servers quote both delimiter and mailbox::
(\HasNoChildren \Sent) "/" "Sent"
Others quote only the delimiter and leave the mailbox as an atom::
(\HasNoChildren \Sent) "/" Sent
The parser must therefore parse the delimiter token separately instead of
blindly taking the last quoted value.
"""
line = _decode_item(list_response_line).strip()
match = re.match(
r'^\((?P<flags>[^)]*)\)\s+'
r'(?P<delimiter>"(?:[^"\\]|\\.)*"|NIL|[^\s]+)\s+'
r'(?P<mailbox>.+?)\s*$',
line,
re.IGNORECASE,
)
if match:
flags = {part.lower() for part in match.group("flags").split()}
mailbox = _unquote_imap_token(match.group("mailbox"))
if mailbox:
return mailbox, flags
return None
# Fallback for non-standard server lines: prefer the final token.
parts = line.split(maxsplit=2)
if len(parts) >= 3:
flags_text = parts[0].strip("()")
flags = {part.lower() for part in flags_text.split()}
mailbox = _unquote_imap_token(parts[2])
if mailbox:
return mailbox, flags
return None
def _detect_sent_folder(parsed: list[tuple[str, set[str]]]) -> str | None:
for name, flags in parsed:
if "\\sent" in flags or "\\sentmail" in flags:
return name
common_names = [
"Sent",
"Sent Items",
"Sent Messages",
"Gesendet",
"Gesendete Elemente",
"INBOX.Sent",
"INBOX/Sent",
]
names = {name.lower(): name for name, _ in parsed}
for candidate in common_names:
if candidate.lower() in names:
return names[candidate.lower()]
return None
def discover_sent_folder(client: imaplib.IMAP4) -> str | None:
typ, data = client.list()
if typ != "OK" or not data:
return None
parsed: list[tuple[str, set[str]]] = []
for item in data:
extracted = _extract_mailbox_name(item)
if extracted:
parsed.append(extracted)
return _detect_sent_folder(parsed)
def _effective_sent_folder(*, config: ImapConfig, requested_folder: str | None, client: imaplib.IMAP4) -> str:
if requested_folder and requested_folder != "auto":
return requested_folder
if config.sent_folder and config.sent_folder != "auto":
return config.sent_folder
discovered = discover_sent_folder(client)
if discovered:
return discovered
raise ImapConfigurationError("Could not discover Sent folder; configure delivery.imap_append_sent.folder or server.imap.sent_folder")
def test_imap_login(*, imap_config: ImapConfig) -> ImapLoginTestResult:
"""Open an IMAP connection and authenticate if credentials are configured.
This is a side-effect-free connection test for the WebUI. It does not select
a mailbox and does not append any message.
"""
host, port = _require_imap_config(imap_config)
if is_mock_imap_host(imap_config.host):
return ImapLoginTestResult(
host=host,
port=port,
security=imap_config.security.value,
authenticated=bool(imap_config.username and imap_config.password),
)
client = _open_imap(imap_config)
try:
return ImapLoginTestResult(
host=host,
port=port,
security=imap_config.security.value,
authenticated=bool(imap_config.username and imap_config.password),
)
finally:
try:
client.logout()
except Exception:
pass
def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
"""Return folders visible through IMAP LIST and the best sent-folder guess."""
host, port = _require_imap_config(imap_config)
if is_mock_imap_host(imap_config.host):
folders = [ImapMailboxInfo(name=str(item["name"]), flags=list(item.get("flags") or [])) for item in MOCK_IMAP_FOLDERS]
return ImapFolderListResult(
host=host,
port=port,
security=imap_config.security.value,
folders=folders,
detected_sent_folder="Sent",
)
client = _open_imap(imap_config)
try:
typ, data = client.list()
if typ != "OK":
raise ImapAppendError(f"IMAP folder listing failed: {data!r}", temporary=True)
parsed: list[tuple[str, set[str]]] = []
folders: list[ImapMailboxInfo] = []
for item in data or []:
extracted = _extract_mailbox_name(item)
if not extracted:
continue
name, flags = extracted
parsed.append((name, flags))
folders.append(ImapMailboxInfo(name=name, flags=sorted(flags)))
return ImapFolderListResult(
host=host,
port=port,
security=imap_config.security.value,
folders=folders,
detected_sent_folder=_detect_sent_folder(parsed),
)
finally:
try:
client.logout()
except Exception:
pass
def append_message_to_sent(
message_bytes: bytes,
*,
imap_config: ImapConfig,
folder: str | None = None,
) -> ImapAppendResult:
"""Append a sent MIME message to the configured IMAP Sent folder.
The SMTP send remains authoritative. APPEND is a separate best-effort step
and should not be used to decide whether an email was sent.
"""
host, port = _require_imap_config(imap_config)
if is_mock_imap_host(imap_config.host):
if consume_fail_next_imap():
raise ImapAppendError("Mock IMAP configured to fail the next append", temporary=False)
target_folder = folder or (imap_config.sent_folder if imap_config.sent_folder and imap_config.sent_folder != "auto" else "Sent")
record = record_imap_append(message_bytes, folder=target_folder, imap_host=imap_config.host)
return ImapAppendResult(
host=host,
port=port,
security=imap_config.security.value,
folder=target_folder,
bytes_appended=len(message_bytes),
response=f"mock append stored as {record.id}",
)
client: imaplib.IMAP4 | None = None
try:
client = _open_imap(imap_config)
target_folder = _effective_sent_folder(config=imap_config, requested_folder=folder, client=client)
internal_date = imaplib.Time2Internaldate(time.time())
typ, data = client.append(target_folder, "\\Seen", internal_date, message_bytes)
if typ != "OK":
raise ImapAppendError(f"IMAP APPEND failed for folder {target_folder!r}: {data!r}", temporary=False)
response = "; ".join(_decode_item(item) for item in (data or [])) or None
return ImapAppendResult(
host=host,
port=port,
security=imap_config.security.value,
folder=target_folder,
bytes_appended=len(message_bytes),
response=response,
)
except ImapAppendError:
raise
except (OSError, socket.timeout, imaplib.IMAP4.abort) as exc:
raise ImapAppendError(f"IMAP append failed: {exc}", temporary=True) from exc
except imaplib.IMAP4.error as exc:
raise ImapAppendError(f"IMAP append failed: {exc}", temporary=False) from exc
finally:
if client is not None:
try:
client.logout()
except Exception:
pass

View File

@@ -0,0 +1,57 @@
from __future__ import annotations
import time
from dataclasses import dataclass
from redis import Redis
from redis.exceptions import RedisError
from govoplan_mail.backend.runtime import settings
@dataclass(frozen=True, slots=True)
class RateLimitDecision:
key: str
messages_per_minute: int
gap_seconds: float
waited_seconds: float
def _redis_client() -> Redis:
return Redis.from_url(settings.redis_url, decode_responses=True)
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.
"""
messages_per_minute = max(1, int(messages_per_minute or 1))
gap = 60.0 / messages_per_minute
if not 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"
lock_key = f"multimailer:ratelimit:{key}:lock"
waited = 0.0
try:
client = _redis_client()
with client.lock(lock_key, timeout=30, blocking_timeout=30):
now = time.time()
raw_next = client.get(redis_key)
next_allowed = float(raw_next) if raw_next else now
if next_allowed > now:
waited = next_allowed - now
time.sleep(waited)
now = time.time()
client.set(redis_key, now + gap, ex=max(60, int(gap * 10)))
except (RedisError, TimeoutError, ValueError):
# Development fallback: do not fail sending because Redis is absent.
waited = 0.0
return RateLimitDecision(key=key, messages_per_minute=messages_per_minute, gap_seconds=gap, waited_seconds=waited)

View File

@@ -0,0 +1,322 @@
from __future__ import annotations
import copy
import smtplib
import ssl
from dataclasses import dataclass
from email.message import EmailMessage
from email.utils import formataddr
from govoplan_mail.backend.config import SmtpConfig, TransportSecurity
from govoplan_mail.backend.dev.mock_mailbox import (
consume_fail_next_smtp,
get_failures,
is_mock_smtp_host,
record_smtp_delivery,
)
class SmtpConfigurationError(ValueError):
"""Raised when SMTP settings are incomplete or inconsistent."""
class SmtpSendError(RuntimeError):
"""Raised when an SMTP send attempt fails.
``temporary`` means an explicit response allows a later retry. An
``outcome_unknown`` failure happened after SMTP transmission may have
started, so automatic retry is intentionally forbidden.
"""
def __init__(self, message: str, *, temporary: bool = False, outcome_unknown: bool = False):
super().__init__(message)
self.temporary = temporary
self.outcome_unknown = outcome_unknown
@dataclass(frozen=True, slots=True)
class SmtpLoginTestResult:
host: str
port: int
security: str
authenticated: bool
@dataclass(frozen=True, slots=True)
class SmtpSendResult:
host: str
port: int
security: str
envelope_from: str
envelope_recipients: list[str]
refused_recipients: dict[str, tuple[int, bytes | str]]
@property
def accepted_count(self) -> int:
return len(self.envelope_recipients) - len(self.refused_recipients)
def _require_smtp_config(config: SmtpConfig) -> tuple[str, int]:
if not config.host:
raise SmtpConfigurationError("SMTP host is required")
if not config.port:
raise SmtpConfigurationError("SMTP port is required")
if bool(config.username) != bool(config.password):
raise SmtpConfigurationError("SMTP username and password must be provided together, or both omitted")
return config.host, config.port
def _open_smtp(config: SmtpConfig) -> smtplib.SMTP:
host, port = _require_smtp_config(config)
context = ssl.create_default_context()
try:
if config.security == TransportSecurity.TLS:
smtp: smtplib.SMTP = smtplib.SMTP_SSL(host=host, port=port, timeout=config.timeout_seconds, context=context)
smtp.ehlo()
else:
smtp = smtplib.SMTP(host=host, port=port, timeout=config.timeout_seconds)
smtp.ehlo()
if config.security == TransportSecurity.STARTTLS:
smtp.starttls(context=context)
smtp.ehlo()
if config.username and config.password:
smtp.login(config.username, config.password)
return smtp
except Exception:
# If construction/login fails after a socket was created, smtplib usually closes
# on GC, but explicit cleanup is safer when the variable exists.
try:
smtp.quit() # type: ignore[possibly-undefined]
except Exception:
pass
raise
def _decode_refused(refused: dict[str, tuple[int, bytes]]) -> dict[str, tuple[int, bytes | str]]:
normalized: dict[str, tuple[int, bytes | str]] = {}
for recipient, (code, response) in refused.items():
try:
normalized[recipient] = (code, response.decode("utf-8", errors="replace"))
except AttributeError:
normalized[recipient] = (code, response)
return normalized
def test_smtp_login(*, smtp_config: SmtpConfig) -> SmtpLoginTestResult:
"""Open an SMTP connection and authenticate if credentials are configured.
This is intentionally side-effect free: it does not send a message and it
never receives envelope or recipient data. It is used by the WebUI to check
whether the configured transport can be reached before a campaign is built
or queued.
"""
host, port = _require_smtp_config(smtp_config)
if is_mock_smtp_host(smtp_config.host):
host, port = _require_smtp_config(smtp_config)
return SmtpLoginTestResult(
host=host,
port=port,
security=smtp_config.security.value,
authenticated=bool(smtp_config.username and smtp_config.password),
)
smtp = _open_smtp(smtp_config)
try:
return SmtpLoginTestResult(
host=host,
port=port,
security=smtp_config.security.value,
authenticated=bool(smtp_config.username and smtp_config.password),
)
finally:
try:
smtp.quit()
except Exception:
try:
smtp.close()
except Exception:
pass
def prepare_test_message(
message: EmailMessage,
*,
test_recipient: str,
test_recipient_name: str | None = None,
) -> EmailMessage:
"""Return a safe copy of a generated campaign message for test delivery.
The original recipient headers are removed so a test send cannot accidentally
leak the real To/Cc list or deliver to the real recipients. The envelope
recipient must also be supplied separately to send_email_message().
"""
test_message = copy.deepcopy(message)
for header in ["To", "Cc", "Bcc"]:
if header in test_message:
del test_message[header]
# Replace potential previous marker headers if the user test-sends an EML twice.
for header in ["X-MultiMailer-Test-Send"]:
if header in test_message:
del test_message[header]
test_message["To"] = formataddr((test_recipient_name or test_recipient, test_recipient))
test_message["X-MultiMailer-Test-Send"] = "true"
return test_message
def _send_smtp_payload(
message: EmailMessage | bytes,
*,
smtp_config: SmtpConfig,
envelope_from: str,
envelope_recipients: list[str],
) -> SmtpSendResult:
host, port = _require_smtp_config(smtp_config)
if not envelope_from:
raise SmtpConfigurationError("SMTP envelope sender is required")
if not envelope_recipients:
raise SmtpConfigurationError("at least one SMTP envelope recipient is required")
if is_mock_smtp_host(smtp_config.host):
if consume_fail_next_smtp():
raise SmtpSendError("Mock SMTP configured to fail the next send")
failures = get_failures()
reject_text = str(failures.get("smtp_reject_recipients_containing") or "").strip().lower()
refused: dict[str, tuple[int, bytes]] = {}
accepted = list(envelope_recipients)
if reject_text:
refused = {
recipient: (550, b"mock recipient rejected")
for recipient in envelope_recipients
if reject_text in recipient.lower()
}
accepted = [recipient for recipient in envelope_recipients if recipient not in refused]
if not accepted:
raise SmtpSendError(f"all mock SMTP recipients were refused: {_decode_refused(refused)}")
record_smtp_delivery(
message,
envelope_from=envelope_from,
envelope_recipients=accepted,
smtp_host=smtp_config.host,
)
return SmtpSendResult(
host=host,
port=port,
security=smtp_config.security.value,
envelope_from=envelope_from,
envelope_recipients=list(envelope_recipients),
refused_recipients=_decode_refused(refused),
)
try:
smtp = _open_smtp(smtp_config)
except smtplib.SMTPAuthenticationError as exc:
raise SmtpSendError(
f"SMTP authentication failed: {exc.smtp_code} {exc.smtp_error!r}",
temporary=False,
) from exc
except smtplib.SMTPResponseException as exc:
raise SmtpSendError(
f"SMTP connection error: {exc.smtp_code} {exc.smtp_error!r}",
temporary=400 <= int(exc.smtp_code) < 500,
) from exc
except (OSError, smtplib.SMTPException) as exc:
# No message transmission has begun yet; a later explicit retry is safe.
raise SmtpSendError(f"SMTP connection failed: {exc}", temporary=True) from exc
try:
if isinstance(message, bytes):
refused = smtp.sendmail(envelope_from, envelope_recipients, message)
else:
refused = smtp.send_message(
message,
from_addr=envelope_from,
to_addrs=envelope_recipients,
)
except smtplib.SMTPRecipientsRefused as exc:
raise SmtpSendError(
f"all SMTP recipients were refused: {_decode_refused(exc.recipients)}",
temporary=False,
) from exc
except smtplib.SMTPSenderRefused as exc:
raise SmtpSendError(
f"SMTP sender was refused: {exc.smtp_code} {exc.smtp_error!r}",
temporary=400 <= int(exc.smtp_code) < 500,
) from exc
except smtplib.SMTPResponseException as exc:
# An explicit SMTP response means the server rejected the transaction;
# retryability follows the response class.
raise SmtpSendError(
f"SMTP error: {exc.smtp_code} {exc.smtp_error!r}",
temporary=400 <= int(exc.smtp_code) < 500,
) from exc
except (OSError, smtplib.SMTPServerDisconnected, smtplib.SMTPException) as exc:
# A connection loss after DATA began can happen after the server accepted
# the message but before the client received the final response.
raise SmtpSendError(
f"SMTP outcome is unknown after transmission started: {exc}",
outcome_unknown=True,
) from exc
finally:
try:
smtp.quit()
except Exception:
try:
smtp.close()
except Exception:
pass
return SmtpSendResult(
host=host,
port=port,
security=smtp_config.security.value,
envelope_from=envelope_from,
envelope_recipients=list(envelope_recipients),
refused_recipients=_decode_refused(refused),
)
def send_email_bytes(
message_bytes: bytes,
*,
smtp_config: SmtpConfig,
envelope_from: str,
envelope_recipients: list[str],
) -> SmtpSendResult:
"""Send exact RFC 5322 bytes through SMTP without reserializing the message."""
return _send_smtp_payload(
message_bytes,
smtp_config=smtp_config,
envelope_from=envelope_from,
envelope_recipients=envelope_recipients,
)
def send_email_message(
message: EmailMessage,
*,
smtp_config: SmtpConfig,
envelope_from: str,
envelope_recipients: list[str],
) -> SmtpSendResult:
"""Send an EmailMessage through SMTP.
This low-level function deliberately receives explicit envelope sender and
recipients. Headers and SMTP envelope are related but not identical; Bcc and
future bounce-address handling depend on keeping them separate. Campaign
delivery uses send_email_bytes() so the generated EML is not reserialized.
"""
return _send_smtp_payload(
message,
smtp_config=smtp_config,
envelope_from=envelope_from,
envelope_recipients=envelope_recipients,
)

View File

23
webui/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "@govoplan/mail-webui",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/mail-profiles.css": "./src/styles/mail-profiles.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.0",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
}
}

1
webui/src/api/client.ts Normal file
View File

@@ -0,0 +1 @@
export { apiFetch, apiUrl, authHeaders, csrfToken, apiDownload } from "@govoplan/core-webui";

265
webui/src/api/mail.ts Normal file
View File

@@ -0,0 +1,265 @@
import type { ApiSettings } from "../types";
import { apiFetch } from "./client";
export type MailSecurity = "plain" | "tls" | "starttls";
export type MailProfileScope = "system" | "tenant" | "user" | "group" | "campaign";
export type MailSmtpTestPayload = {
host?: string | null;
port?: number | null;
username?: string | null;
password?: string | null;
security?: MailSecurity;
timeout_seconds?: number;
};
export type MailImapTestPayload = MailSmtpTestPayload & {
enabled?: boolean;
sent_folder?: string | null;
};
export type MailConnectionTestResponse = {
ok: boolean;
protocol: "smtp" | "imap";
host?: string | null;
port?: number | null;
security?: MailSecurity | string | null;
message: string;
details?: Record<string, unknown>;
};
export type MailImapFolderResponse = {
name: string;
flags?: string[];
};
export type MailImapFolderListResponse = {
ok: boolean;
protocol: "imap";
host?: string | null;
port?: number | null;
security?: MailSecurity | string | null;
message: string;
folders: MailImapFolderResponse[];
detected_sent_folder?: string | null;
details?: Record<string, unknown>;
};
export type MailServerProfile = {
id: string;
tenant_id?: string | null;
scope_type: MailProfileScope;
scope_id?: string | null;
name: string;
slug: string;
description?: string | null;
is_active: boolean;
smtp: MailSmtpTestPayload;
imap?: MailImapTestPayload | null;
smtp_password_configured: boolean;
imap_password_configured: boolean;
created_at: string;
updated_at: string;
};
export type MailServerProfileListResponse = { profiles: MailServerProfile[] };
export const mailProfilePatternKeys = [
"smtp_hosts",
"imap_hosts",
"envelope_senders",
"from_headers",
"recipient_domains"
] as const;
export type MailProfilePatternKey = typeof mailProfilePatternKeys[number];
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
export type MailCredentialPolicy = {
inherit?: boolean | null;
allow_override?: boolean | null;
};
export type MailProfilePolicy = {
allowed_profile_ids?: string[] | null;
allow_user_profiles?: boolean | null;
allow_group_profiles?: boolean | null;
allow_campaign_profiles?: boolean | null;
smtp_credentials?: MailCredentialPolicy | null;
imap_credentials?: MailCredentialPolicy | null;
whitelist?: MailProfilePatternRules | null;
blacklist?: MailProfilePatternRules | null;
};
export type MailProfilePolicyResponse = {
scope_type: MailProfileScope;
scope_id?: string | null;
policy: MailProfilePolicy;
effective_policy?: MailProfilePolicy | null;
};
export type MailServerProfilePayload = {
name: string;
slug?: string | null;
description?: string | null;
is_active?: boolean;
scope_type?: MailProfileScope;
scope_id?: string | null;
smtp: MailSmtpTestPayload;
imap?: MailImapTestPayload | null;
};
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
const params = new URLSearchParams();
if (includeInactive) params.set("include_inactive", "true");
if (campaignId) params.set("campaign_id", campaignId);
const suffix = params.toString() ? `?${params.toString()}` : "";
const response = await apiFetch<MailServerProfileListResponse>(settings, `/api/v1/mail/profiles${suffix}`);
return response.profiles ?? [];
}
export async function createMailServerProfile(settings: ApiSettings, payload: MailServerProfilePayload): Promise<MailServerProfile> {
return apiFetch<MailServerProfile>(settings, "/api/v1/mail/profiles", {
method: "POST",
body: JSON.stringify(payload)
});
}
export type MailServerProfileUpdatePayload = Partial<MailServerProfilePayload> & { clear_imap?: boolean };
export async function updateMailServerProfile(settings: ApiSettings, profileId: string, payload: MailServerProfileUpdatePayload): Promise<MailServerProfile> {
return apiFetch<MailServerProfile>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export async function deactivateMailServerProfile(settings: ApiSettings, profileId: string): Promise<MailServerProfile> {
return apiFetch<MailServerProfile>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}`, { method: "DELETE" });
}
export async function getMailProfilePolicy(
settings: ApiSettings,
scopeType: MailProfileScope,
scopeId?: string | null,
campaignId?: string | null
): Promise<MailProfilePolicyResponse> {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
if (campaignId) params.set("campaign_id", campaignId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch<MailProfilePolicyResponse>(settings, `/api/v1/mail/policies/${encodeURIComponent(scopeType)}${suffix}`);
}
export async function updateMailProfilePolicy(
settings: ApiSettings,
scopeType: MailProfileScope,
policy: MailProfilePolicy,
scopeId?: string | null
): Promise<MailProfilePolicyResponse> {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch<MailProfilePolicyResponse>(settings, `/api/v1/mail/policies/${encodeURIComponent(scopeType)}${suffix}`, {
method: "PUT",
body: JSON.stringify({ policy })
});
}
export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-smtp`, { method: "POST" });
}
export async function testMailProfileImap(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-imap`, { method: "POST" });
}
export async function listMailProfileImapFolders(settings: ApiSettings, profileId: string): Promise<MailImapFolderListResponse> {
return apiFetch<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`, { method: "POST" });
}
export async function testSmtpSettings(
settings: ApiSettings,
payload: MailSmtpTestPayload
): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, "/api/v1/mail/test-smtp", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function testImapSettings(
settings: ApiSettings,
payload: MailImapTestPayload
): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, "/api/v1/mail/test-imap", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function listImapFolders(
settings: ApiSettings,
payload: MailImapTestPayload
): Promise<MailImapFolderListResponse> {
return apiFetch<MailImapFolderListResponse>(settings, "/api/v1/mail/list-imap-folders", {
method: "POST",
body: JSON.stringify(payload)
});
}
export type MockMailboxMessage = {
id: string;
kind: "smtp" | "imap_append" | string;
created_at: string;
envelope_from?: string | null;
envelope_recipients?: string[];
subject?: string | null;
from_header?: string | null;
to_header?: string | null;
cc_header?: string | null;
bcc_header?: string | null;
message_id?: string | null;
size_bytes?: number;
body_preview?: string | null;
attachment_count?: number;
folder?: string | null;
raw_eml?: string | null;
headers?: Record<string, string>;
attachments?: Array<{ filename?: string | null; content_type?: string | null; size_bytes?: number }>;
};
export type MockMailboxListResponse = {
messages: MockMailboxMessage[];
};
export type MockMailboxMessageResponse = {
message: MockMailboxMessage;
};
export type MockMailboxFailureConfig = {
fail_next_smtp?: boolean | null;
fail_next_imap?: boolean | null;
smtp_reject_recipients_containing?: string | null;
};
export async function listMockMailboxMessages(settings: ApiSettings, kind?: string): Promise<MockMailboxListResponse> {
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : "";
return apiFetch<MockMailboxListResponse>(settings, `/api/v1/dev/mailbox/messages${suffix}`);
}
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {
return apiFetch<MockMailboxMessageResponse>(settings, `/api/v1/dev/mailbox/messages/${encodeURIComponent(id)}`);
}
export async function clearMockMailboxMessages(settings: ApiSettings): Promise<{ deleted_count: number }> {
return apiFetch<{ deleted_count: number }>(settings, "/api/v1/dev/mailbox/messages", { method: "DELETE" });
}
export async function updateMockMailboxFailures(settings: ApiSettings, payload: MockMailboxFailureConfig): Promise<{ config: MockMailboxFailureConfig }> {
return apiFetch<{ config: MockMailboxFailureConfig }>(settings, "/api/v1/dev/mailbox/failures", {
method: "POST",
body: JSON.stringify(payload)
});
}

View File

@@ -0,0 +1,2 @@
import { Button } from "@govoplan/core-webui";
export default Button;

View File

@@ -0,0 +1,2 @@
import { Card } from "@govoplan/core-webui";
export default Card;

View File

@@ -0,0 +1,2 @@
import { ConfirmDialog } from "@govoplan/core-webui";
export default ConfirmDialog;

View File

@@ -0,0 +1,2 @@
import { Dialog } from "@govoplan/core-webui";
export default Dialog;

View File

@@ -0,0 +1,2 @@
import { DismissibleAlert } from "@govoplan/core-webui";
export default DismissibleAlert;

View File

@@ -0,0 +1,2 @@
import { FormField } from "@govoplan/core-webui";
export default FormField;

View File

@@ -0,0 +1,2 @@
import { LoadingFrame } from "@govoplan/core-webui";
export default LoadingFrame;

View File

@@ -0,0 +1,2 @@
import { LoadingIndicator } from "@govoplan/core-webui";
export default LoadingIndicator;

View File

@@ -0,0 +1,2 @@
import { MetricCard } from "@govoplan/core-webui";
export default MetricCard;

View File

@@ -0,0 +1,2 @@
import { PageTitle } from "@govoplan/core-webui";
export default PageTitle;

View File

@@ -0,0 +1,2 @@
import { StatusBadge } from "@govoplan/core-webui";
export default StatusBadge;

View File

@@ -0,0 +1,2 @@
import { Stepper } from "@govoplan/core-webui";
export default Stepper;

View File

@@ -0,0 +1,2 @@
import { ToggleSwitch } from "@govoplan/core-webui";
export default ToggleSwitch;

View File

@@ -0,0 +1,2 @@
import { EmailAddressInput } from "@govoplan/core-webui";
export default EmailAddressInput;

View File

@@ -0,0 +1,2 @@
import { FieldLabel } from "@govoplan/core-webui";
export default FieldLabel;

View File

@@ -0,0 +1,2 @@
import { InlineHelp } from "@govoplan/core-webui";
export default InlineHelp;

View File

@@ -0,0 +1,4 @@
import { DataGrid, DataGridEmptyAction, DataGridRowActions } from "@govoplan/core-webui";
export type { DataGridColumn, DataGridListOption, DataGridPagination, DataGridQueryState, DataGridSortDirection } from "@govoplan/core-webui";
export { DataGridEmptyAction, DataGridRowActions };
export default DataGrid;

View File

@@ -0,0 +1,864 @@
import { useEffect, useMemo, useState } from "react";
import { Pencil, Plus, Trash2 } from "lucide-react";
import type { ApiSettings } from "../../types";
import {
createMailServerProfile,
deactivateMailServerProfile,
getMailProfilePolicy,
listMailServerProfiles,
mailProfilePatternKeys,
updateMailProfilePolicy,
updateMailServerProfile,
type MailCredentialPolicy,
type MailImapTestPayload,
type MailProfilePatternKey,
type MailProfilePatternRules,
type MailProfilePolicy,
type MailProfileScope,
type MailSecurity,
type MailServerProfile,
type MailServerProfilePayload,
type MailServerProfileUpdatePayload,
type MailSmtpTestPayload
} from "../../api/mail";
import Button from "../../components/Button";
import Card from "../../components/Card";
import ConfirmDialog from "../../components/ConfirmDialog";
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;
label: string;
secondary?: string | null;
};
type ProfileDraft = {
name: string;
slug: string;
description: string;
isActive: boolean;
smtpHost: string;
smtpPort: string;
smtpSecurity: MailSecurity;
smtpUsername: string;
smtpPassword: string;
smtpTimeout: string;
imapEnabled: boolean;
imapHost: string;
imapPort: string;
imapSecurity: MailSecurity;
imapUsername: string;
imapPassword: string;
imapSentFolder: string;
imapTimeout: string;
};
type MailProfileScopeManagerProps = {
settings: ApiSettings;
scopeType: MailProfileScope;
scopeId?: string | null;
targetOptions?: MailProfileTargetOption[];
targetLabel?: string;
profileTitle?: string;
policyTitle?: string;
canWriteProfiles: boolean;
canManageCredentials: boolean;
canWritePolicy: boolean;
};
type MailProfilePolicyEditorProps = {
settings: ApiSettings;
scopeType: MailProfileScope;
scopeId?: string | null;
campaignId?: string | null;
profiles: MailServerProfile[];
ownerUserId?: string | null;
ownerGroupId?: string | null;
canWrite: boolean;
locked?: boolean;
title?: string;
description?: string;
onSaved?: () => void | Promise<void>;
};
type EditingProfile = MailServerProfile | "new" | null;
type PolicyFlagValue = "inherit" | "allow" | "deny";
type CredentialInheritanceValue = "inherit" | "profile" | "local";
type CredentialOverrideValue = "inherit" | "allow" | "lock";
const securityOptions: MailSecurity[] = ["plain", "tls", "starttls"];
const patternLabels: Record<MailProfilePatternKey, string> = {
smtp_hosts: "SMTP hostnames",
imap_hosts: "IMAP hostnames",
envelope_senders: "Envelope senders",
from_headers: "From headers",
recipient_domains: "Recipient domains"
};
const blankPolicy: MailProfilePolicy = {
allowed_profile_ids: [],
allow_user_profiles: null,
allow_group_profiles: null,
allow_campaign_profiles: null,
smtp_credentials: {},
imap_credentials: {},
whitelist: {},
blacklist: {}
};
export function MailProfileScopeManager({
settings,
scopeType,
scopeId = null,
targetOptions = [],
targetLabel = "Target",
profileTitle = "Mail server profiles",
policyTitle = "Mail profile policy",
canWriteProfiles,
canManageCredentials,
canWritePolicy
}: MailProfileScopeManagerProps) {
const [profiles, setProfiles] = useState<MailServerProfile[]>([]);
const [selectedTargetId, setSelectedTargetId] = useState(scopeId || targetOptions[0]?.id || "");
const [editing, setEditing] = useState<EditingProfile>(null);
const [deactivating, setDeactivating] = useState<MailServerProfile | null>(null);
const [draft, setDraft] = useState<ProfileDraft>(emptyProfileDraft());
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const requiresTarget = scopeType === "user" || scopeType === "group" || scopeType === "campaign";
const activeScopeId = scopeId || selectedTargetId || null;
const scopeReady = !requiresTarget || Boolean(activeScopeId);
useEffect(() => {
if (scopeId) {
setSelectedTargetId(scopeId);
return;
}
if (targetOptions.length > 0 && !targetOptions.some((option) => option.id === selectedTargetId)) {
setSelectedTargetId(targetOptions[0].id);
}
}, [scopeId, selectedTargetId, targetOptions]);
useEffect(() => { void loadProfiles(); }, [settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
async function loadProfiles() {
setLoading(true);
setError("");
try {
setProfiles(await listMailServerProfiles(settings, true));
} catch (err) {
setProfiles([]);
setError(errorMessage(err));
} finally {
setLoading(false);
}
}
const scopedProfiles = useMemo(
() => profiles.filter((profile) => profileBelongsToEditableScope(profile, scopeType, activeScopeId)),
[activeScopeId, profiles, scopeType]
);
function openCreate() {
setDraft(emptyProfileDraft());
setEditing("new");
setError("");
setSuccess("");
}
function openEdit(profile: MailServerProfile) {
setDraft(profileToDraft(profile));
setEditing(profile);
setError("");
setSuccess("");
}
async function saveProfile() {
if (!editing || !scopeReady) return;
setBusy(true);
setError("");
setSuccess("");
try {
if (editing === "new") {
const payload = createProfilePayload(draft, scopeType, activeScopeId);
const created = await createMailServerProfile(settings, payload);
setSuccess(`Profile ${created.name} created.`);
} else {
const payload = updateProfilePayload(draft, editing);
const updated = await updateMailServerProfile(settings, editing.id, payload);
setSuccess(`Profile ${updated.name} updated.`);
}
setEditing(null);
await loadProfiles();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
async function deactivateProfile() {
if (!deactivating) return;
setBusy(true);
setError("");
setSuccess("");
try {
await deactivateMailServerProfile(settings, deactivating.id);
setSuccess(`Profile ${deactivating.name} deactivated.`);
setDeactivating(null);
await loadProfiles();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
const columns = useMemo<DataGridColumn<MailServerProfile>[]>(() => [
{ id: "name", header: "Profile", width: "minmax(220px, 1fr)", sticky: "start", resizable: true, sortable: true, filterable: true, value: (profile) => `${profile.name} ${profile.slug}`, render: (profile) => <div><strong>{profile.name}</strong><div className="muted small-note">{profile.slug}</div></div> },
{ id: "scope", header: "Scope", width: 120, resizable: true, sortable: true, filterable: true, value: (profile) => scopeLabel(profile) },
{ id: "smtp", header: "SMTP", width: 220, sortable: true, filterable: true, value: (profile) => transportLabel(profile.smtp), render: (profile) => <TransportCell profile={profile} protocol="smtp" /> },
{ id: "imap", header: "IMAP", width: 220, sortable: true, filterable: true, value: (profile) => profile.imap ? transportLabel(profile.imap) : "Not configured", render: (profile) => <TransportCell profile={profile} protocol="imap" /> },
{ id: "status", header: "Status", width: 110, sortable: true, filterable: true, value: (profile) => profile.is_active ? "Active" : "Inactive", render: (profile) => <span className={`status-badge ${profile.is_active ? "success" : "neutral"}`}>{profile.is_active ? "Active" : "Inactive"}</span> },
{ id: "actions", header: "Actions", width: 145, sticky: "end", align: "right", render: (profile) => <div className="admin-icon-actions">
<Button type="button" className="admin-icon-button" title={`Edit ${profile.name}`} aria-label={`Edit ${profile.name}`} onClick={() => openEdit(profile)} disabled={!canWriteProfiles || busy}><Pencil size={16} /></Button>
<Button type="button" variant="danger" className="admin-icon-button" title={`Deactivate ${profile.name}`} aria-label={`Deactivate ${profile.name}`} onClick={() => setDeactivating(profile)} disabled={!canWriteProfiles || busy || !profile.is_active}><Trash2 size={16} /></Button>
</div> }
], [busy, canWriteProfiles]);
return (
<div className="mail-profile-manager">
{targetOptions.length > 0 && !scopeId && (
<Card title={`${targetLabel} scope`}>
<div className="mail-profile-target-row">
<FormField label={targetLabel}>
<select value={selectedTargetId} onChange={(event) => setSelectedTargetId(event.target.value)} disabled={loading || busy}>
{targetOptions.map((option) => <option key={option.id} value={option.id}>{option.secondary ? `${option.label} (${option.secondary})` : option.label}</option>)}
</select>
</FormField>
</div>
</Card>
)}
<MailProfilePolicyEditor
settings={settings}
scopeType={scopeType}
scopeId={activeScopeId}
profiles={profiles}
canWrite={canWritePolicy}
title={policyTitle}
onSaved={loadProfiles}
/>
<Card
title={profileTitle}
actions={
<div className="button-row compact-actions">
<Button onClick={() => void loadProfiles()} disabled={loading}>{loading ? "Loading…" : "Reload"}</Button>
<Button variant="primary" onClick={openCreate} disabled={!canWriteProfiles || !scopeReady || busy}><span className="button-icon-label"><Plus size={16} />New profile</span></Button>
</div>
}
>
<div className="admin-table-surface mail-profile-table-surface">
<DataGrid
id={`mail-profiles-${scopeType}-${activeScopeId || "root"}`}
rows={scopeReady ? scopedProfiles : []}
columns={columns}
initialFit="container"
getRowKey={(profile) => profile.id}
emptyText={scopeReady ? "No profiles in this scope." : `Select a ${targetLabel.toLowerCase()} first.`}
/>
</div>
</Card>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
<Dialog
open={editing !== null}
title={editing === "new" ? "Create mail profile" : "Edit mail profile"}
onClose={() => !busy && setEditing(null)}
className="admin-dialog admin-dialog-wide mail-profile-dialog"
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></>}
>
<ProfileForm draft={draft} setDraft={setDraft} editing={editing} busy={busy} canWrite={canWriteProfiles} canManageCredentials={canManageCredentials} />
</Dialog>
<ConfirmDialog
open={Boolean(deactivating)}
title="Deactivate mail profile"
message={`Deactivate ${deactivating?.name || "this profile"}? Campaign drafts using it will need another allowed profile before sending.`}
confirmLabel="Deactivate"
tone="danger"
busy={busy}
onCancel={() => setDeactivating(null)}
onConfirm={() => void deactivateProfile()}
/>
</div>
);
}
export function MailProfilePolicyEditor({
settings,
scopeType,
scopeId = null,
campaignId = null,
profiles,
ownerUserId = null,
ownerGroupId = null,
canWrite,
locked = false,
title = "Mail profile policy",
description = "Allowed profiles and wildcard rules for this scope.",
onSaved
}: MailProfilePolicyEditorProps) {
const [policy, setPolicy] = useState<MailProfilePolicy>(blankPolicy);
const [effectivePolicy, setEffectivePolicy] = useState<MailProfilePolicy | null>(null);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const requiresTarget = scopeType === "user" || scopeType === "group" || scopeType === "campaign";
const scopeReady = !requiresTarget || Boolean(scopeId);
useEffect(() => { void loadPolicy(); }, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, scopeId, campaignId]);
async function loadPolicy() {
setError("");
setSuccess("");
if (!scopeReady) {
setPolicy(blankPolicy);
setEffectivePolicy(null);
return;
}
setLoading(true);
try {
const response = await getMailProfilePolicy(settings, scopeType, scopeId, campaignId);
setPolicy(normalizePolicy(response.policy));
setEffectivePolicy(response.effective_policy ? normalizePolicy(response.effective_policy) : null);
} catch (err) {
setPolicy(blankPolicy);
setEffectivePolicy(null);
setError(errorMessage(err));
} finally {
setLoading(false);
}
}
async function savePolicy() {
if (!scopeReady) return;
setBusy(true);
setError("");
setSuccess("");
try {
const response = await updateMailProfilePolicy(settings, scopeType, normalizePolicy(policy), scopeId);
setPolicy(normalizePolicy(response.policy));
setEffectivePolicy(response.effective_policy ? normalizePolicy(response.effective_policy) : null);
setSuccess("Mail profile policy saved.");
await onSaved?.();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
const candidateProfiles = useMemo(
() => profileCandidatesForPolicy(profiles, scopeType, scopeId, ownerUserId, ownerGroupId),
[ownerGroupId, ownerUserId, profiles, scopeId, scopeType]
);
const selectedProfileIds = new Set(policy.allowed_profile_ids ?? []);
const disabled = locked || busy || loading || !canWrite || !scopeReady;
function patchPolicy(patch: Partial<MailProfilePolicy>) {
setPolicy((current) => normalizePolicy({ ...current, ...patch }));
}
function toggleAllowedProfile(profileId: string, checked: boolean) {
const next = new Set(policy.allowed_profile_ids ?? []);
if (checked) next.add(profileId);
else next.delete(profileId);
patchPolicy({ allowed_profile_ids: [...next].sort() });
}
function setFlag(key: "allow_user_profiles" | "allow_group_profiles" | "allow_campaign_profiles", value: PolicyFlagValue) {
patchPolicy({ [key]: flagToBoolean(value) });
}
function setCredentialPolicy(protocol: "smtp" | "imap", patch: Partial<MailCredentialPolicy>) {
const key = protocol === "smtp" ? "smtp_credentials" : "imap_credentials";
patchPolicy({ [key]: normalizeCredentialPolicy({ ...(policy[key] ?? {}), ...patch }) });
}
function setPattern(kind: "whitelist" | "blacklist", key: MailProfilePatternKey, text: string) {
const nextRules = { ...(policy[kind] ?? {}) };
const parsed = parsePatternList(text);
if (parsed.length > 0) nextRules[key] = parsed;
else delete nextRules[key];
patchPolicy({ [kind]: nextRules });
}
return (
<Card
title={title}
actions={
<div className="button-row compact-actions">
<Button onClick={() => void loadPolicy()} disabled={loading || !scopeReady}>{loading ? "Loading…" : "Reload"}</Button>
<Button variant="primary" onClick={() => void savePolicy()} disabled={disabled}>{busy ? "Saving…" : "Save policy"}</Button>
</div>
}
>
<div className="mail-policy-editor">
{description && <p className="muted small-note mail-policy-description">{description}</p>}
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
<section className="mail-policy-section">
<div className="subsection-heading split">
<h3>Profile allow-list</h3>
<Button onClick={() => patchPolicy({ allowed_profile_ids: [] })} disabled={disabled || selectedProfileIds.size === 0}>Clear allow-list</Button>
</div>
<p className="muted small-note">{selectedProfileIds.size === 0 ? "No local profile allow-list is set." : `${selectedProfileIds.size} profile(s) allowed by this scope.`}</p>
<div className="mail-profile-checkbox-grid">
{candidateProfiles.map((profile) => (
<label className="mail-profile-checkbox" key={profile.id}>
<input type="checkbox" checked={selectedProfileIds.has(profile.id)} disabled={disabled} onChange={(event) => toggleAllowedProfile(profile.id, event.target.checked)} />
<span><strong>{profile.name}</strong><small>{scopeLabel(profile)} · {transportLabel(profile.smtp)}</small></span>
</label>
))}
{candidateProfiles.length === 0 && <p className="muted small-note">No profiles are visible for this policy scope.</p>}
</div>
</section>
<section className="mail-policy-section">
<h3>Lower-level profile definitions</h3>
<div className="admin-form-grid three-columns mail-policy-flags">
<PolicyFlagSelect label="User profiles" value={booleanToFlag(policy.allow_user_profiles)} disabled={disabled} onChange={(value) => setFlag("allow_user_profiles", value)} />
<PolicyFlagSelect label="Group profiles" value={booleanToFlag(policy.allow_group_profiles)} disabled={disabled} onChange={(value) => setFlag("allow_group_profiles", value)} />
<PolicyFlagSelect label="Campaign profiles" value={booleanToFlag(policy.allow_campaign_profiles)} disabled={disabled} onChange={(value) => setFlag("allow_campaign_profiles", value)} />
</div>
</section>
<section className="mail-policy-section">
<h3>Credential inheritance</h3>
<div className="admin-form-grid two-columns mail-policy-flags">
<CredentialInheritanceSelect label="SMTP credentials" value={credentialInheritanceToValue(policy.smtp_credentials?.inherit)} disabled={disabled} onChange={(value) => setCredentialPolicy("smtp", { inherit: valueToCredentialInheritance(value) })} />
<CredentialOverrideSelect label="SMTP override" value={credentialOverrideToValue(policy.smtp_credentials?.allow_override)} disabled={disabled} onChange={(value) => setCredentialPolicy("smtp", { allow_override: valueToCredentialOverride(value) })} />
<CredentialInheritanceSelect label="IMAP credentials" value={credentialInheritanceToValue(policy.imap_credentials?.inherit)} disabled={disabled} onChange={(value) => setCredentialPolicy("imap", { inherit: valueToCredentialInheritance(value) })} />
<CredentialOverrideSelect label="IMAP override" value={credentialOverrideToValue(policy.imap_credentials?.allow_override)} disabled={disabled} onChange={(value) => setCredentialPolicy("imap", { allow_override: valueToCredentialOverride(value) })} />
</div>
</section>
<section className="mail-policy-section">
<h3>Wildcard rules</h3>
<div className="mail-policy-pattern-grid">
<div>
<h4>Whitelist</h4>
{mailProfilePatternKeys.map((key) => <PatternTextarea key={`whitelist-${key}`} label={patternLabels[key]} value={patternsToText(policy.whitelist?.[key])} disabled={disabled} onChange={(text) => setPattern("whitelist", key, text)} />)}
</div>
<div>
<h4>Blacklist</h4>
{mailProfilePatternKeys.map((key) => <PatternTextarea key={`blacklist-${key}`} label={patternLabels[key]} value={patternsToText(policy.blacklist?.[key])} disabled={disabled} onChange={(text) => setPattern("blacklist", key, text)} />)}
</div>
</div>
</section>
{effectivePolicy && (
<section className="mail-policy-section mail-policy-effective">
<h3>Effective campaign policy</h3>
<div className="mail-policy-effective-grid">
<div><span>Profiles</span><strong>{effectiveProfileLabel(effectivePolicy.allowed_profile_ids)}</strong></div>
<div><span>User profiles</span><strong>{effectivePolicy.allow_user_profiles ? "Allowed" : "Blocked"}</strong></div>
<div><span>Group profiles</span><strong>{effectivePolicy.allow_group_profiles ? "Allowed" : "Blocked"}</strong></div>
<div><span>Campaign profiles</span><strong>{effectivePolicy.allow_campaign_profiles ? "Allowed" : "Blocked"}</strong></div>
<div><span>SMTP credentials</span><strong>{credentialPolicyLabel(effectivePolicy.smtp_credentials)}</strong></div>
<div><span>IMAP credentials</span><strong>{credentialPolicyLabel(effectivePolicy.imap_credentials)}</strong></div>
</div>
</section>
)}
</div>
</Card>
);
}
function ProfileForm({
draft,
setDraft,
editing,
busy,
canWrite,
canManageCredentials
}: {
draft: ProfileDraft;
setDraft: (draft: ProfileDraft) => void;
editing: EditingProfile;
busy: boolean;
canWrite: boolean;
canManageCredentials: boolean;
}) {
const disabled = busy || !canWrite;
const credentialDisabled = disabled || !canManageCredentials;
const existingHasImap = editing !== "new" && Boolean(editing?.imap);
const imapToggleDisabled = disabled || (!canManageCredentials && existingHasImap && draft.imapEnabled);
return (
<div className="mail-profile-form">
<div className="admin-form-grid two-columns">
<FormField label="Name"><input value={draft.name} disabled={disabled} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="Slug"><input value={draft.slug} disabled={disabled} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} placeholder="Generated from name" /></FormField>
<FormField label="Status"><select value={draft.isActive ? "active" : "inactive"} disabled={disabled} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>
<FormField label="Description"><textarea rows={3} value={draft.description} disabled={disabled} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
</div>
<h3>SMTP</h3>
<div className="admin-form-grid three-columns">
<FormField label="Host"><input value={draft.smtpHost} disabled={disabled} onChange={(event) => setDraft({ ...draft, smtpHost: event.target.value })} /></FormField>
<FormField label="Port"><input type="number" min={1} max={65535} value={draft.smtpPort} disabled={disabled} onChange={(event) => setDraft({ ...draft, smtpPort: event.target.value })} /></FormField>
<FormField label="Security"><SecuritySelect value={draft.smtpSecurity} disabled={disabled} onChange={(smtpSecurity) => setDraft({ ...draft, smtpSecurity })} /></FormField>
<FormField label="Username"><input value={draft.smtpUsername} disabled={disabled} onChange={(event) => setDraft({ ...draft, smtpUsername: event.target.value })} /></FormField>
<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>
<FormField label="Timeout seconds"><input type="number" min={1} value={draft.smtpTimeout} disabled={disabled} onChange={(event) => setDraft({ ...draft, smtpTimeout: event.target.value })} /></FormField>
</div>
<div className="mail-profile-imap-heading">
<h3>IMAP</h3>
<ToggleSwitch label="Enable IMAP" checked={draft.imapEnabled} disabled={imapToggleDisabled} onChange={(imapEnabled) => setDraft({ ...draft, imapEnabled })} />
</div>
<div className="admin-form-grid three-columns">
<FormField label="Host"><input value={draft.imapHost} disabled={disabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapHost: event.target.value })} /></FormField>
<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>
<FormField label="Security"><SecuritySelect value={draft.imapSecurity} disabled={disabled || !draft.imapEnabled} onChange={(imapSecurity) => setDraft({ ...draft, imapSecurity })} /></FormField>
<FormField label="Username"><input value={draft.imapUsername} disabled={disabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapUsername: event.target.value })} /></FormField>
<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>
<FormField label="Sent folder"><input value={draft.imapSentFolder} disabled={disabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapSentFolder: event.target.value })} /></FormField>
<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>
</div>
</div>
);
}
function PolicyFlagSelect({ label, value, disabled, onChange }: { label: string; value: PolicyFlagValue; disabled: boolean; onChange: (value: PolicyFlagValue) => void }) {
return (
<FormField label={label}>
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as PolicyFlagValue)}>
<option value="inherit">Inherit</option>
<option value="allow">Explicit allow</option>
<option value="deny">Deny</option>
</select>
</FormField>
);
}
function CredentialInheritanceSelect({ label, value, disabled, onChange }: { label: string; value: CredentialInheritanceValue; disabled: boolean; onChange: (value: CredentialInheritanceValue) => void }) {
return (
<FormField label={label}>
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as CredentialInheritanceValue)}>
<option value="inherit">Inherit parent decision</option>
<option value="profile">Inherit profile credentials</option>
<option value="local">Require local credentials</option>
</select>
</FormField>
);
}
function CredentialOverrideSelect({ label, value, disabled, onChange }: { label: string; value: CredentialOverrideValue; disabled: boolean; onChange: (value: CredentialOverrideValue) => void }) {
return (
<FormField label={label}>
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as CredentialOverrideValue)}>
<option value="inherit">Inherit</option>
<option value="allow">Allow lower override</option>
<option value="lock">Lock lower levels</option>
</select>
</FormField>
);
}
function PatternTextarea({ label, value, disabled, onChange }: { label: string; value: string; disabled: boolean; onChange: (value: string) => void }) {
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" }) {
const transport = protocol === "smtp" ? profile.smtp : profile.imap;
if (!transport) return <span className="muted">Not configured</span>;
const hasPassword = protocol === "smtp" ? profile.smtp_password_configured : profile.imap_password_configured;
return <div><strong>{transportLabel(transport)}</strong><div className="muted small-note">Password {hasPassword ? "configured" : "not configured"}</div></div>;
}
function emptyProfileDraft(): ProfileDraft {
return {
name: "",
slug: "",
description: "",
isActive: true,
smtpHost: "",
smtpPort: "587",
smtpSecurity: "starttls",
smtpUsername: "",
smtpPassword: "",
smtpTimeout: "30",
imapEnabled: false,
imapHost: "",
imapPort: "993",
imapSecurity: "tls",
imapUsername: "",
imapPassword: "",
imapSentFolder: "auto",
imapTimeout: "30"
};
}
function profileToDraft(profile: MailServerProfile): ProfileDraft {
return {
name: profile.name,
slug: profile.slug,
description: profile.description || "",
isActive: profile.is_active,
smtpHost: stringValue(profile.smtp.host),
smtpPort: stringValue(profile.smtp.port ?? 587),
smtpSecurity: readSecurity(String(profile.smtp.security || "starttls"), "starttls"),
smtpUsername: stringValue(profile.smtp.username),
smtpPassword: "",
smtpTimeout: stringValue(profile.smtp.timeout_seconds ?? 30),
imapEnabled: Boolean(profile.imap),
imapHost: stringValue(profile.imap?.host),
imapPort: stringValue(profile.imap?.port ?? 993),
imapSecurity: readSecurity(String(profile.imap?.security || "tls"), "tls"),
imapUsername: stringValue(profile.imap?.username),
imapPassword: "",
imapSentFolder: stringValue(profile.imap?.sent_folder || "auto"),
imapTimeout: stringValue(profile.imap?.timeout_seconds ?? 30)
};
}
function createProfilePayload(draft: ProfileDraft, scopeType: MailProfileScope, scopeId: string | null): MailServerProfilePayload {
return {
name: draft.name.trim(),
slug: emptyToNull(draft.slug),
description: emptyToNull(draft.description),
is_active: draft.isActive,
scope_type: scopeType,
scope_id: scopeId,
smtp: smtpPayload(draft, false),
imap: draft.imapEnabled ? imapPayload(draft, false) : null
};
}
function updateProfilePayload(draft: ProfileDraft, profile: MailServerProfile): MailServerProfileUpdatePayload {
return {
name: draft.name.trim(),
slug: emptyToNull(draft.slug),
description: draft.description.trim(),
is_active: draft.isActive,
smtp: smtpPayload(draft, true),
imap: draft.imapEnabled ? imapPayload(draft, true) : null,
clear_imap: !draft.imapEnabled && Boolean(profile.imap)
};
}
function smtpPayload(draft: ProfileDraft, preserveBlankPassword: boolean): MailSmtpTestPayload {
const payload: MailSmtpTestPayload = {
host: emptyToNull(draft.smtpHost),
port: numberOrNull(draft.smtpPort),
username: emptyToNull(draft.smtpUsername),
security: draft.smtpSecurity,
timeout_seconds: numberOrDefault(draft.smtpTimeout, 30)
};
if (draft.smtpPassword || !preserveBlankPassword) payload.password = emptyToNull(draft.smtpPassword, false);
return payload;
}
function imapPayload(draft: ProfileDraft, preserveBlankPassword: boolean): MailImapTestPayload {
const payload: MailImapTestPayload = {
enabled: true,
host: emptyToNull(draft.imapHost),
port: numberOrNull(draft.imapPort),
username: emptyToNull(draft.imapUsername),
security: draft.imapSecurity,
sent_folder: emptyToNull(draft.imapSentFolder) || "auto",
timeout_seconds: numberOrDefault(draft.imapTimeout, 30)
};
if (draft.imapPassword || !preserveBlankPassword) payload.password = emptyToNull(draft.imapPassword, false);
return payload;
}
function normalizePolicy(value: MailProfilePolicy | null | undefined): MailProfilePolicy {
return {
allowed_profile_ids: [...(value?.allowed_profile_ids ?? [])].filter(Boolean),
allow_user_profiles: value?.allow_user_profiles ?? null,
allow_group_profiles: value?.allow_group_profiles ?? null,
allow_campaign_profiles: value?.allow_campaign_profiles ?? null,
smtp_credentials: normalizeCredentialPolicy(value?.smtp_credentials),
imap_credentials: normalizeCredentialPolicy(value?.imap_credentials),
whitelist: normalizeRules(value?.whitelist),
blacklist: normalizeRules(value?.blacklist)
};
}
function normalizeCredentialPolicy(value: MailCredentialPolicy | null | undefined): MailCredentialPolicy {
return {
inherit: typeof value?.inherit === "boolean" ? value.inherit : null,
allow_override: typeof value?.allow_override === "boolean" ? value.allow_override : null
};
}
function normalizeRules(value: MailProfilePatternRules | null | undefined): MailProfilePatternRules {
const result: MailProfilePatternRules = {};
for (const key of mailProfilePatternKeys) {
const patterns = (value?.[key] ?? []).map((pattern) => pattern.trim()).filter(Boolean);
if (patterns.length > 0) result[key] = patterns;
}
return result;
}
function profileBelongsToEditableScope(profile: MailServerProfile, scopeType: MailProfileScope, scopeId: string | null): boolean {
if (profile.scope_type !== scopeType) return false;
if (scopeType === "system") return true;
if (scopeType === "tenant") return true;
return Boolean(scopeId) && profile.scope_id === scopeId;
}
function profileCandidatesForPolicy(profiles: MailServerProfile[], scopeType: MailProfileScope, scopeId: string | null, ownerUserId: string | null, ownerGroupId: string | null): MailServerProfile[] {
return profiles
.filter((profile) => {
if (scopeType === "system") return profile.scope_type === "system";
if (profile.scope_type === "system" || profile.scope_type === "tenant") return true;
if (scopeType === "user") return profile.scope_type === "user" && profile.scope_id === scopeId;
if (scopeType === "group") return profile.scope_type === "group" && profile.scope_id === scopeId;
if (scopeType === "campaign") {
if (profile.scope_type === "campaign") return profile.scope_id === scopeId;
if (profile.scope_type === "user") return Boolean(ownerUserId) && profile.scope_id === ownerUserId;
if (profile.scope_type === "group") return Boolean(ownerGroupId) && profile.scope_id === ownerGroupId;
}
return false;
})
.sort((a, b) => `${scopeOrder(a.scope_type)}:${a.name}`.localeCompare(`${scopeOrder(b.scope_type)}:${b.name}`));
}
function scopeOrder(scopeType: MailProfileScope): number {
if (scopeType === "system") return 0;
if (scopeType === "tenant") return 1;
if (scopeType === "user" || scopeType === "group") return 2;
return 3;
}
function credentialInheritanceToValue(value: boolean | null | undefined): CredentialInheritanceValue {
if (value === true) return "profile";
if (value === false) return "local";
return "inherit";
}
function valueToCredentialInheritance(value: CredentialInheritanceValue): boolean | null {
if (value === "profile") return true;
if (value === "local") return false;
return null;
}
function credentialOverrideToValue(value: boolean | null | undefined): CredentialOverrideValue {
if (value === true) return "allow";
if (value === false) return "lock";
return "inherit";
}
function valueToCredentialOverride(value: CredentialOverrideValue): boolean | null {
if (value === "allow") return true;
if (value === "lock") return false;
return null;
}
function credentialPolicyLabel(value: MailCredentialPolicy | null | undefined): string {
const source = value?.inherit === false ? "Local required" : "Profile inherited";
const override = value?.allow_override === false ? "locked" : "override allowed";
return `${source}, ${override}`;
}
function booleanToFlag(value: boolean | null | undefined): PolicyFlagValue {
if (value === true) return "allow";
if (value === false) return "deny";
return "inherit";
}
function flagToBoolean(value: PolicyFlagValue): boolean | null {
if (value === "allow") return true;
if (value === "deny") return false;
return null;
}
function parsePatternList(value: string): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const item of value.split(/[\n,]+/)) {
const pattern = item.trim();
if (pattern && !seen.has(pattern)) {
seen.add(pattern);
result.push(pattern);
}
}
return result;
}
function patternsToText(value: string[] | undefined): string {
return (value ?? []).join("\n");
}
function effectiveProfileLabel(value: string[] | null | undefined): string {
if (!value || value.length === 0) return "No explicit intersection";
return `${value.length} profile(s)`;
}
function transportLabel(transport: MailSmtpTestPayload | MailImapTestPayload | null | undefined): string {
if (!transport) return "Not configured";
const host = transport.host || "No host";
const port = transport.port ? `:${transport.port}` : "";
return `${host}${port}`;
}
function scopeLabel(profile: MailServerProfile): string {
if (profile.scope_type === "system") return "System";
if (profile.scope_type === "tenant") return "Tenant";
if (profile.scope_type === "user") return "User";
if (profile.scope_type === "group") return "Group";
return "Campaign";
}
function readSecurity(value: string, fallback: MailSecurity): MailSecurity {
return securityOptions.includes(value as MailSecurity) ? (value as MailSecurity) : fallback;
}
function emptyToNull(value: string, trim = true): string | null {
const normalized = trim ? value.trim() : value;
return normalized ? normalized : null;
}
function stringValue(value: unknown): string {
if (value === null || value === undefined) return "";
return String(value);
}
function numberOrNull(value: string): number | null {
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
function numberOrDefault(value: string, fallback: number): number {
const parsed = numberOrNull(value);
return parsed ?? fallback;
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}

7
webui/src/index.ts Normal file
View File

@@ -0,0 +1,7 @@
import "./styles/mail-profiles.css";
export { default } from "./module";
export * from "./module";
export * from "./api/mail";
export { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement";
export type { MailProfileTargetOption } from "./features/mail/MailProfileManagement";
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";

10
webui/src/module.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { PlatformWebModule } from "@govoplan/core-webui";
export const mailModule: PlatformWebModule = {
id: "mail",
label: "Mail",
version: "1.0.0",
dependencies: ["access"]
};
export default mailModule;

View File

@@ -0,0 +1,195 @@
.admin-form-grid.three-columns {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.button-icon-label {
display: inline-flex;
align-items: center;
gap: 7px;
}
.mail-profile-manager {
display: grid;
gap: 18px;
}
.mail-profile-target-row {
max-width: 520px;
}
.mail-profile-table-surface {
width: 100%;
max-width: 100%;
min-width: 0;
}
.mail-profile-table-surface > .data-grid-shell {
width: 100%;
max-width: 100%;
min-width: 0;
}
.card-body > .mail-profile-table-surface:only-child {
margin: -22px -24px;
width: calc(100% + 48px);
max-width: inherit;
}
.card-body > .mail-profile-table-surface:only-child > .data-grid-shell {
border: 0;
border-radius: 0;
box-shadow: none;
}
.mail-profile-dialog .dialog-body {
max-height: min(76vh, 820px);
overflow: auto;
}
.mail-profile-form {
display: grid;
gap: 18px;
}
.mail-profile-form h3,
.mail-policy-section h3,
.mail-policy-pattern-grid h4 {
margin: 0;
color: var(--text-strong);
}
.mail-profile-imap-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding-top: 4px;
}
.mail-credential-inheritance-row {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: 12px;
max-width: 760px;
}
.mail-policy-editor {
display: grid;
gap: 18px;
}
.mail-policy-description {
margin: 0;
}
.mail-policy-section {
display: grid;
gap: 12px;
padding-top: 4px;
}
.mail-profile-checkbox-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 10px;
}
.mail-profile-checkbox {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
}
.mail-profile-checkbox input {
width: auto;
margin-top: 3px;
box-shadow: none;
}
.mail-profile-checkbox span {
display: grid;
gap: 2px;
min-width: 0;
}
.mail-profile-checkbox small {
color: var(--muted);
overflow-wrap: anywhere;
}
.mail-policy-pattern-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.mail-policy-pattern-grid > div {
display: grid;
gap: 12px;
align-content: start;
}
.mail-policy-effective {
border-top: 1px solid var(--line);
padding-top: 14px;
}
.mail-policy-effective-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.mail-policy-effective-grid div {
display: grid;
gap: 3px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface-subtle);
}
.mail-policy-effective-grid span {
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.mail-policy-effective-grid strong {
color: var(--text-strong);
}
.status-badge.success {
background: var(--success-bg);
color: var(--success-text);
}
.status-badge.neutral {
background: #e7e4df;
color: #666;
}
@media (max-width: 900px) {
.admin-form-grid.three-columns,
.mail-policy-pattern-grid,
.mail-policy-effective-grid {
grid-template-columns: 1fr;
}
.mail-credential-inheritance-row {
grid-template-columns: 1fr;
}
.mail-profile-imap-heading {
align-items: flex-start;
flex-direction: column;
}
}

1
webui/src/types.ts Normal file
View File

@@ -0,0 +1 @@
export type { ApiSettings, AuthInfo, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext, PlatformWebModule } from "@govoplan/core-webui";