diff --git a/README.md b/README.md index 1d37561..792656e 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8180586 --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/src/govoplan_mail/__init__.py b/src/govoplan_mail/__init__.py new file mode 100644 index 0000000..035718b --- /dev/null +++ b/src/govoplan_mail/__init__.py @@ -0,0 +1 @@ +"""govoplan-mail module package.""" diff --git a/src/govoplan_mail/backend/__init__.py b/src/govoplan_mail/backend/__init__.py new file mode 100644 index 0000000..5ab5106 --- /dev/null +++ b/src/govoplan_mail/backend/__init__.py @@ -0,0 +1 @@ +"""Backend integration for this GovOPlaN module.""" diff --git a/src/govoplan_mail/backend/config.py b/src/govoplan_mail/backend/config.py new file mode 100644 index 0000000..194e35c --- /dev/null +++ b/src/govoplan_mail/backend/config.py @@ -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) diff --git a/src/govoplan_mail/backend/db/__init__.py b/src/govoplan_mail/backend/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_mail/backend/db/models.py b/src/govoplan_mail/backend/db/models.py new file mode 100644 index 0000000..17aa0ac --- /dev/null +++ b/src/govoplan_mail/backend/db/models.py @@ -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() + + diff --git a/src/govoplan_mail/backend/dev/__init__.py b/src/govoplan_mail/backend/dev/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_mail/backend/dev/mock_mailbox.py b/src/govoplan_mail/backend/dev/mock_mailbox.py new file mode 100644 index 0000000..936ee4e --- /dev/null +++ b/src/govoplan_mail/backend/dev/mock_mailbox.py @@ -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 diff --git a/src/govoplan_mail/backend/dev_router.py b/src/govoplan_mail/backend/dev_router.py new file mode 100644 index 0000000..a901244 --- /dev/null +++ b/src/govoplan_mail/backend/dev_router.py @@ -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) diff --git a/src/govoplan_mail/backend/mail_profiles.py b/src/govoplan_mail/backend/mail_profiles.py new file mode 100644 index 0000000..3d7fbcd --- /dev/null +++ b/src/govoplan_mail/backend/mail_profiles.py @@ -0,0 +1,1111 @@ +from __future__ import annotations + +import copy +import fnmatch +import re +from dataclasses import dataclass, field +from typing import Any, Iterable + +from sqlalchemy import and_, or_ +from sqlalchemy.orm import Session + +from govoplan_core.admin.governance import get_system_settings +from govoplan_core.db.models import Group, Tenant, User, UserGroupMembership +from govoplan_campaign.backend.db.models import Campaign +from govoplan_mail.backend.db.models import MailServerProfile +from govoplan_mail.backend.config import ImapConfig, SmtpConfig +from govoplan_core.security.secrets import decrypt_secret, encrypt_secret + + +class MailProfileError(RuntimeError): + pass + + +MAIL_PROFILE_POLICY_SETTINGS_KEY = "mail_profile_policy" +PROFILE_SCOPE_TYPES = {"system", "tenant", "user", "group", "campaign"} +PROFILE_PATTERN_KEYS = ("smtp_hosts", "imap_hosts", "envelope_senders", "from_headers", "recipient_domains") +PROFILE_SCOPE_ORDER = {"system": 0, "tenant": 1, "user": 2, "group": 2, "campaign": 3} + + +@dataclass(slots=True) +class EffectiveCredentialPolicy: + inherit: bool = True + allow_override: bool = True + explicit_inherit: bool = False + inherit_source: str | None = None + + def as_dict(self) -> dict[str, Any]: + return { + "inherit": self.inherit, + "allow_override": self.allow_override, + } + + +@dataclass(slots=True) +class EffectiveMailProfilePolicy: + allowed_profile_id_sets: list[set[str]] = field(default_factory=list) + allow_user_profiles: bool = True + allow_group_profiles: bool = True + allow_campaign_profiles: bool = True + smtp_credentials: EffectiveCredentialPolicy = field(default_factory=EffectiveCredentialPolicy) + imap_credentials: EffectiveCredentialPolicy = field(default_factory=EffectiveCredentialPolicy) + whitelist_groups: dict[str, list[list[str]]] = field(default_factory=lambda: {key: [] for key in PROFILE_PATTERN_KEYS}) + blacklist_patterns: dict[str, list[str]] = field(default_factory=lambda: {key: [] for key in PROFILE_PATTERN_KEYS}) + source_policies: list[dict[str, Any]] = field(default_factory=list) + + def profile_id_allowed(self, profile_id: str) -> bool: + return all(profile_id in allowed for allowed in self.allowed_profile_id_sets) + + def value_allowed(self, key: str, value: str | None) -> tuple[bool, str | None]: + if key not in PROFILE_PATTERN_KEYS or not value: + return True, None + normalized = _normalize_match_value(value) + for pattern in self.blacklist_patterns.get(key, []): + if _pattern_matches(normalized, pattern): + return False, f"{key} value {value!r} is blocked by mail profile blacklist pattern {pattern!r}" + for group in self.whitelist_groups.get(key, []): + if group and not any(_pattern_matches(normalized, pattern) for pattern in group): + return False, f"{key} value {value!r} is not allowed by the effective mail profile whitelist" + return True, None + + def as_dict(self) -> dict[str, Any]: + allowed_profile_ids: list[str] | None = None + if self.allowed_profile_id_sets: + allowed_profile_ids = sorted(set.intersection(*self.allowed_profile_id_sets)) if len(self.allowed_profile_id_sets) > 1 else sorted(next(iter(self.allowed_profile_id_sets))) + whitelist: dict[str, list[str]] = {} + for key, groups in self.whitelist_groups.items(): + flattened = sorted({pattern for group in groups for pattern in group}) + if flattened: + whitelist[key] = flattened + blacklist = {key: patterns for key, patterns in self.blacklist_patterns.items() if patterns} + return { + "allowed_profile_ids": allowed_profile_ids, + "allow_user_profiles": self.allow_user_profiles, + "allow_group_profiles": self.allow_group_profiles, + "allow_campaign_profiles": self.allow_campaign_profiles, + "smtp_credentials": self.smtp_credentials.as_dict(), + "imap_credentials": self.imap_credentials.as_dict(), + "whitelist": whitelist, + "blacklist": blacklist, + } + + +def slugify_profile_name(value: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", value.strip().casefold()).strip("-") + return slug or "mail-profile" + + +def _transport_payload(config: SmtpConfig | ImapConfig) -> tuple[dict[str, Any], str | None, bool]: + payload = config.model_dump(mode="json") + password_was_supplied = "password" in config.model_fields_set + password = payload.pop("password", None) + return payload, password, password_was_supplied + + +def _normalize_scope_type(scope_type: str | None) -> str: + clean = (scope_type or "tenant").strip().casefold() + if clean not in PROFILE_SCOPE_TYPES: + raise MailProfileError("Mail-server profile scope must be system, tenant, user, group or campaign") + return clean + + +def _profile_scope_type(profile: MailServerProfile) -> str: + return _normalize_scope_type(getattr(profile, "scope_type", None) or "tenant") + + +def _profile_scope_id(profile: MailServerProfile) -> str | None: + scope_type = _profile_scope_type(profile) + value = getattr(profile, "scope_id", None) + if value: + return str(value) + if scope_type == "tenant" and profile.tenant_id: + return str(profile.tenant_id) + return None + + +def _clean_string_list(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, str): + value = [value] + if not isinstance(value, list): + raise MailProfileError("Mail profile policy lists must be arrays of strings") + result: list[str] = [] + for item in value: + text = str(item).strip() + if text: + result.append(text) + return result + + +def _meaningful_allow_patterns(patterns: Iterable[str]) -> list[str]: + return [pattern for pattern in patterns if pattern and pattern != "*"] + + +def _normalize_pattern_rules(value: Any) -> dict[str, list[str]]: + if value is None: + return {} + if not isinstance(value, dict): + raise MailProfileError("Mail profile pattern policy must be an object") + rules: dict[str, list[str]] = {} + for key, raw_patterns in value.items(): + if key not in PROFILE_PATTERN_KEYS: + raise MailProfileError(f"Unsupported mail profile pattern key: {key}") + patterns = _clean_string_list(raw_patterns) + if patterns: + rules[key] = patterns + return rules + + +def _normalize_credential_policy(value: Any) -> dict[str, bool | None]: + data = value if isinstance(value, dict) else {} + inherit = data.get("inherit") + if inherit is None: + inherit = data.get("inherit_credentials") + allow_override = data.get("allow_override") + return { + "inherit": inherit if isinstance(inherit, bool) else None, + "allow_override": allow_override if isinstance(allow_override, bool) else None, + } + + +def normalize_mail_profile_policy(payload: dict[str, Any] | None) -> dict[str, Any]: + data = dict(payload or {}) + allowed_ids = _clean_string_list(data.get("allowed_profile_ids")) + whitelist = _normalize_pattern_rules(data.get("whitelist") or data.get("allow_patterns") or {}) + blacklist = _normalize_pattern_rules(data.get("blacklist") or data.get("deny_patterns") or {}) + normalized: dict[str, Any] = { + "allowed_profile_ids": allowed_ids, + "smtp_credentials": _normalize_credential_policy(data.get("smtp_credentials")), + "imap_credentials": _normalize_credential_policy(data.get("imap_credentials")), + "whitelist": whitelist, + "blacklist": blacklist, + } + for key in ("allow_user_profiles", "allow_group_profiles", "allow_campaign_profiles"): + value = data.get(key) + normalized[key] = value if isinstance(value, bool) else None + return normalized + + +def _policy_from_settings(settings_payload: dict[str, Any] | None) -> dict[str, Any]: + if not isinstance(settings_payload, dict): + return normalize_mail_profile_policy({}) + raw = settings_payload.get(MAIL_PROFILE_POLICY_SETTINGS_KEY, {}) + return normalize_mail_profile_policy(raw if isinstance(raw, dict) else {}) + + +def _policy_from_attr(value: Any) -> dict[str, Any]: + return normalize_mail_profile_policy(value if isinstance(value, dict) else {}) + + +def _merge_credential_policy(current: EffectiveCredentialPolicy, data: dict[str, Any], *, source: str) -> None: + inherit = data.get("inherit") + if isinstance(inherit, bool): + if current.allow_override: + current.inherit = inherit + current.explicit_inherit = True + current.inherit_source = source + elif inherit == current.inherit: + current.explicit_inherit = True + + allow_override = data.get("allow_override") + if isinstance(allow_override, bool): + if current.allow_override: + current.allow_override = allow_override + elif allow_override is False: + current.allow_override = False + + +def _merge_policy(policy: EffectiveMailProfilePolicy, data: dict[str, Any], *, source: str) -> None: + normalized = normalize_mail_profile_policy(data) + policy.source_policies.append(normalized) + allowed_ids = _meaningful_allow_patterns(normalized.get("allowed_profile_ids") or []) + if allowed_ids: + policy.allowed_profile_id_sets.append(set(allowed_ids)) + if normalized.get("allow_user_profiles") is False: + policy.allow_user_profiles = False + if normalized.get("allow_group_profiles") is False: + policy.allow_group_profiles = False + if normalized.get("allow_campaign_profiles") is False: + policy.allow_campaign_profiles = False + _merge_credential_policy(policy.smtp_credentials, normalized.get("smtp_credentials") or {}, source=source) + _merge_credential_policy(policy.imap_credentials, normalized.get("imap_credentials") or {}, source=source) + whitelist = normalized.get("whitelist") or {} + blacklist = normalized.get("blacklist") or {} + for key in PROFILE_PATTERN_KEYS: + allow_patterns = _meaningful_allow_patterns(whitelist.get(key, [])) + if allow_patterns: + policy.whitelist_groups.setdefault(key, []).append(allow_patterns) + deny_patterns = _clean_string_list(blacklist.get(key, [])) + if deny_patterns: + policy.blacklist_patterns.setdefault(key, []).extend(deny_patterns) + + +def _owner_context( + session: Session, + *, + tenant_id: str, + campaign_id: str | None = None, + owner_user_id: str | None = None, + owner_group_id: str | None = None, +) -> tuple[Campaign | None, str | None, str | None]: + campaign = None + if campaign_id: + campaign = session.get(Campaign, campaign_id) + if campaign is None or campaign.tenant_id != tenant_id: + raise MailProfileError("Campaign not found for mail-server profile policy") + owner_user_id = campaign.owner_user_id + owner_group_id = campaign.owner_group_id + return campaign, owner_user_id, owner_group_id + + +def effective_mail_profile_policy( + session: Session, + *, + tenant_id: str, + campaign_id: str | None = None, + owner_user_id: str | None = None, + owner_group_id: str | None = None, +) -> EffectiveMailProfilePolicy: + campaign, owner_user_id, owner_group_id = _owner_context( + session, + tenant_id=tenant_id, + campaign_id=campaign_id, + owner_user_id=owner_user_id, + owner_group_id=owner_group_id, + ) + tenant = session.get(Tenant, tenant_id) + if tenant is None: + raise MailProfileError("Tenant not found for mail-server profile policy") + + policy = EffectiveMailProfilePolicy() + system_settings = get_system_settings(session) + _merge_policy(policy, _policy_from_settings(system_settings.settings or {}), source="system") + _merge_policy(policy, _policy_from_settings(tenant.settings or {}), source="tenant") + if owner_user_id: + user = session.get(User, owner_user_id) + if user and user.tenant_id == tenant_id: + _merge_policy(policy, _policy_from_attr(user.mail_profile_policy), source="user") + if owner_group_id: + group = session.get(Group, owner_group_id) + if group and group.tenant_id == tenant_id: + _merge_policy(policy, _policy_from_attr(group.mail_profile_policy), source="group") + if campaign is not None: + _merge_policy(policy, _policy_from_attr(campaign.mail_profile_policy), source="campaign") + return policy + + +def parent_mail_profile_policy( + session: Session, + *, + tenant_id: str, + scope_type: str, + scope_id: str | None = None, +) -> EffectiveMailProfilePolicy: + clean_scope = _normalize_scope_type(scope_type) + tenant = session.get(Tenant, tenant_id) + if tenant is None: + raise MailProfileError("Tenant not found for mail-server profile policy") + + policy = EffectiveMailProfilePolicy() + system_settings = get_system_settings(session) + _merge_policy(policy, _policy_from_settings(system_settings.settings or {}), source="system") + if clean_scope == "tenant": + return policy + + _merge_policy(policy, _policy_from_settings(tenant.settings or {}), source="tenant") + if clean_scope in {"user", "group"}: + return policy + + if clean_scope != "campaign" or not scope_id: + return policy + campaign = session.get(Campaign, scope_id) + if campaign is None or campaign.tenant_id != tenant_id: + raise MailProfileError("Campaign policy not found") + if campaign.owner_user_id: + user = session.get(User, campaign.owner_user_id) + if user and user.tenant_id == tenant_id: + _merge_policy(policy, _policy_from_attr(user.mail_profile_policy), source="user") + if campaign.owner_group_id: + group = session.get(Group, campaign.owner_group_id) + if group and group.tenant_id == tenant_id: + _merge_policy(policy, _policy_from_attr(group.mail_profile_policy), source="group") + return policy + + +def _normalize_match_value(value: str) -> str: + return str(value).strip().casefold() + + +def _pattern_matches(value: str, pattern: str) -> bool: + clean_pattern = _normalize_match_value(pattern) + if clean_pattern == "*": + return True + return fnmatch.fnmatchcase(value, clean_pattern) + + +def _assert_policy_value(policy: EffectiveMailProfilePolicy, key: str, value: str | None) -> None: + allowed, reason = policy.value_allowed(key, value) + if not allowed: + raise MailProfileError(reason or "Mail profile policy blocked this value") + + +def _domain_from_email(value: str | None) -> str | None: + if not value: + return None + text = str(value).strip().casefold() + if "@" not in text: + return text or None + return text.rsplit("@", 1)[1] or None + + +def _assert_transport_values_allowed(policy: EffectiveMailProfilePolicy, smtp: SmtpConfig | dict[str, Any] | None, imap: ImapConfig | dict[str, Any] | None) -> None: + smtp_host = smtp.host if isinstance(smtp, SmtpConfig) else smtp.get("host") if isinstance(smtp, dict) else None + imap_host = imap.host if isinstance(imap, ImapConfig) else imap.get("host") if isinstance(imap, dict) else None + _assert_policy_value(policy, "smtp_hosts", str(smtp_host) if smtp_host else None) + _assert_policy_value(policy, "imap_hosts", str(imap_host) if imap_host else None) + + +def assert_mail_policy_allows_transport( + session: Session, + *, + tenant_id: str, + smtp: SmtpConfig | dict[str, Any] | None, + imap: ImapConfig | dict[str, Any] | None = None, + campaign_id: str | None = None, + owner_user_id: str | None = None, + owner_group_id: str | None = None, +) -> None: + policy = effective_mail_profile_policy( + session, + tenant_id=tenant_id, + campaign_id=campaign_id, + owner_user_id=owner_user_id, + owner_group_id=owner_group_id, + ) + _assert_transport_values_allowed(policy, smtp, imap) + + +def assert_mail_policy_allows_send( + session: Session, + *, + tenant_id: str, + campaign_id: str, + smtp: SmtpConfig | dict[str, Any] | None, + imap: ImapConfig | dict[str, Any] | None = None, + envelope_sender: str | None = None, + from_header: str | None = None, + recipients: Iterable[str] = (), +) -> None: + policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id) + _assert_transport_values_allowed(policy, smtp, imap) + _assert_policy_value(policy, "envelope_senders", envelope_sender) + _assert_policy_value(policy, "from_headers", from_header) + for recipient in recipients: + _assert_policy_value(policy, "recipient_domains", _domain_from_email(recipient)) + + +def _scope_tuple_for_create( + session: Session, + *, + tenant_id: str, + scope_type: str | None, + scope_id: str | None, + user_id: str | None, +) -> tuple[str | None, str, str | None]: + clean_scope = _normalize_scope_type(scope_type) + clean_scope_id = str(scope_id).strip() if scope_id else None + if clean_scope == "system": + return None, clean_scope, None + if clean_scope == "tenant": + return tenant_id, clean_scope, tenant_id + if clean_scope == "user": + target_id = clean_scope_id or user_id + if not target_id: + raise MailProfileError("User-scoped mail-server profiles require a user scope_id") + user = session.get(User, target_id) + if user is None or user.tenant_id != tenant_id: + raise MailProfileError("User scope is not part of the active tenant") + return tenant_id, clean_scope, target_id + if clean_scope == "group": + if not clean_scope_id: + raise MailProfileError("Group-scoped mail-server profiles require a group scope_id") + group = session.get(Group, clean_scope_id) + if group is None or group.tenant_id != tenant_id: + raise MailProfileError("Group scope is not part of the active tenant") + return tenant_id, clean_scope, clean_scope_id + if not clean_scope_id: + raise MailProfileError("Campaign-scoped mail-server profiles require a campaign scope_id") + campaign = session.get(Campaign, clean_scope_id) + if campaign is None or campaign.tenant_id != tenant_id: + raise MailProfileError("Campaign scope is not part of the active tenant") + return tenant_id, clean_scope, clean_scope_id + + +def _ensure_scope_allows_profile_creation( + session: Session, + *, + tenant_id: str, + scope_type: str, + scope_id: str | None, +) -> None: + if scope_type in {"system", "tenant"}: + return + if scope_type == "campaign": + policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=scope_id) + if not policy.allow_campaign_profiles: + raise MailProfileError("Campaign-scoped mail-server profiles are disabled by policy") + return + if scope_type == "user": + policy = effective_mail_profile_policy(session, tenant_id=tenant_id, owner_user_id=scope_id) + if not policy.allow_user_profiles: + raise MailProfileError("User-scoped mail-server profiles are disabled by policy") + return + if scope_type == "group": + policy = effective_mail_profile_policy(session, tenant_id=tenant_id, owner_group_id=scope_id) + if not policy.allow_group_profiles: + raise MailProfileError("Group-scoped mail-server profiles are disabled by policy") + + +def _ensure_unique_slug( + session: Session, + *, + scope_type: str, + scope_id: str | None, + slug: str, + profile_id: str | None = None, +) -> None: + query = session.query(MailServerProfile).filter( + MailServerProfile.scope_type == scope_type, + MailServerProfile.scope_id.is_(None) if scope_id is None else MailServerProfile.scope_id == scope_id, + MailServerProfile.slug == slug, + ) + if profile_id: + query = query.filter(MailServerProfile.id != profile_id) + if query.one_or_none() is not None: + raise MailProfileError("A mail-server profile with this slug already exists in this scope") + + +def _profile_visible_filter(tenant_id: str): + return or_(MailServerProfile.scope_type == "system", MailServerProfile.tenant_id == tenant_id) + + +def _context_profile_clauses(campaign: Campaign, policy: EffectiveMailProfilePolicy) -> list[Any]: + clauses: list[Any] = [ + MailServerProfile.scope_type == "system", + and_(MailServerProfile.tenant_id == campaign.tenant_id, MailServerProfile.scope_type == "tenant"), + ] + if policy.allow_user_profiles and campaign.owner_user_id: + clauses.append(and_(MailServerProfile.tenant_id == campaign.tenant_id, MailServerProfile.scope_type == "user", MailServerProfile.scope_id == campaign.owner_user_id)) + if policy.allow_group_profiles and campaign.owner_group_id: + clauses.append(and_(MailServerProfile.tenant_id == campaign.tenant_id, MailServerProfile.scope_type == "group", MailServerProfile.scope_id == campaign.owner_group_id)) + if policy.allow_campaign_profiles: + clauses.append(and_(MailServerProfile.tenant_id == campaign.tenant_id, MailServerProfile.scope_type == "campaign", MailServerProfile.scope_id == campaign.id)) + return clauses + + +def _profile_transport_allowed(profile: MailServerProfile, policy: EffectiveMailProfilePolicy) -> bool: + try: + _assert_transport_values_allowed(policy, profile.smtp_config or {}, profile.imap_config or {}) + return True + except MailProfileError: + return False + + +def _profile_allowed_for_context( + profile: MailServerProfile, + *, + tenant_id: str, + policy: EffectiveMailProfilePolicy, + campaign: Campaign | None = None, + owner_user_id: str | None = None, + owner_group_id: str | None = None, +) -> bool: + scope_type = _profile_scope_type(profile) + scope_id = _profile_scope_id(profile) + if not profile.is_active: + return False + if not policy.profile_id_allowed(profile.id): + return False + if scope_type == "system": + pass + elif scope_type == "tenant": + if profile.tenant_id != tenant_id: + return False + elif scope_type == "user": + if not policy.allow_user_profiles or profile.tenant_id != tenant_id or scope_id != owner_user_id: + return False + elif scope_type == "group": + if not policy.allow_group_profiles or profile.tenant_id != tenant_id or scope_id != owner_group_id: + return False + elif scope_type == "campaign": + if not campaign or not policy.allow_campaign_profiles or profile.tenant_id != tenant_id or scope_id != campaign.id: + return False + else: + return False + return _profile_transport_allowed(profile, policy) + + +def list_mail_server_profiles( + session: Session, + *, + tenant_id: str, + include_inactive: bool = False, + campaign_id: str | None = None, +) -> list[MailServerProfile]: + if campaign_id: + campaign = session.get(Campaign, campaign_id) + if campaign is None or campaign.tenant_id != tenant_id: + raise MailProfileError("Campaign not found for mail-server profiles") + policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign.id) + query = session.query(MailServerProfile).filter(or_(*_context_profile_clauses(campaign, policy))) + if not include_inactive: + query = query.filter(MailServerProfile.is_active.is_(True)) + profiles = query.all() + profiles = [ + profile for profile in profiles + if (include_inactive or profile.is_active) + and _profile_allowed_for_context( + profile, + tenant_id=tenant_id, + policy=policy, + campaign=campaign, + owner_user_id=campaign.owner_user_id, + owner_group_id=campaign.owner_group_id, + ) + ] + else: + query = session.query(MailServerProfile).filter(_profile_visible_filter(tenant_id)) + if not include_inactive: + query = query.filter(MailServerProfile.is_active.is_(True)) + profiles = query.all() + return sorted(profiles, key=lambda item: (PROFILE_SCOPE_ORDER.get(_profile_scope_type(item), 99), (item.name or "").casefold())) + + +def get_mail_server_profile( + session: Session, + *, + tenant_id: str, + profile_id: str, + require_active: bool = False, +) -> MailServerProfile: + profile = session.get(MailServerProfile, profile_id) + if profile is None or (_profile_scope_type(profile) != "system" and profile.tenant_id != tenant_id): + raise MailProfileError("Mail-server profile not found") + if require_active and not profile.is_active: + raise MailProfileError("Mail-server profile is inactive") + return profile + + +def ensure_mail_profile_allowed_for_campaign( + session: Session, + *, + tenant_id: str, + campaign_id: str, + profile_id: str, + require_active: bool = True, +) -> MailServerProfile: + campaign = session.get(Campaign, campaign_id) + if campaign is None or campaign.tenant_id != tenant_id: + raise MailProfileError("Campaign not found for mail-server profile policy") + profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=profile_id, require_active=require_active) + policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign.id) + if not _profile_allowed_for_context( + profile, + tenant_id=tenant_id, + policy=policy, + campaign=campaign, + owner_user_id=campaign.owner_user_id, + owner_group_id=campaign.owner_group_id, + ): + raise MailProfileError("Mail-server profile is not allowed for this campaign owner or policy") + return profile + + +def _credential_policy_for_protocol(policy: EffectiveMailProfilePolicy, protocol: str) -> EffectiveCredentialPolicy: + if protocol == "smtp": + return policy.smtp_credentials + if protocol == "imap": + return policy.imap_credentials + raise MailProfileError("Credential policy protocol must be smtp or imap") + + +def _legacy_profile_credentials_inherited(server: dict[str, Any], protocol: str) -> bool: + return server.get(f"inherit_{protocol}_credentials") is not False + + +def profile_credentials_inherited_for_server(policy: EffectiveMailProfilePolicy, server: dict[str, Any], protocol: str) -> bool: + credential_policy = _credential_policy_for_protocol(policy, protocol) + legacy_key = f"inherit_{protocol}_credentials" + if legacy_key in server and credential_policy.allow_override and credential_policy.inherit_source != "campaign": + return _legacy_profile_credentials_inherited(server, protocol) + return credential_policy.inherit + + +def effective_profile_credentials_inherited( + session: Session, + *, + tenant_id: str, + server: dict[str, Any], + protocol: str, + campaign_id: str | None = None, + owner_user_id: str | None = None, + owner_group_id: str | None = None, +) -> bool: + policy = effective_mail_profile_policy( + session, + tenant_id=tenant_id, + campaign_id=campaign_id, + owner_user_id=owner_user_id, + owner_group_id=owner_group_id, + ) + return profile_credentials_inherited_for_server(policy, server, protocol) + + +def _profile_has_password(profile: MailServerProfile, protocol: str) -> bool: + if protocol == "smtp": + return bool(profile.smtp_password_encrypted) + if protocol == "imap": + return bool(profile.imap_password_encrypted) + return False + + +def _profile_has_transport(profile: MailServerProfile, protocol: str) -> bool: + if protocol == "smtp": + return True + return bool(profile.imap_config) + + +def _assert_profile_credentials_allowed_for_server(profile: MailServerProfile, policy: EffectiveMailProfilePolicy, server: dict[str, Any]) -> None: + for protocol in ("smtp", "imap"): + if not _profile_has_transport(profile, protocol): + continue + credential_policy = _credential_policy_for_protocol(policy, protocol) + legacy_key = f"inherit_{protocol}_credentials" + if legacy_key in server: + legacy_inherit = _legacy_profile_credentials_inherited(server, protocol) + if (not credential_policy.allow_override or credential_policy.inherit_source == "campaign") and legacy_inherit != credential_policy.inherit: + raise MailProfileError(f"{protocol.upper()} credential inheritance is locked by the effective mail profile policy") + inherited = profile_credentials_inherited_for_server(policy, server, protocol) + username, password = _transport_credentials_from_server(server, protocol) + if inherited and (username or password): + raise MailProfileError(f"{protocol.upper()} credentials must not be stored on the campaign while inherited profile credentials are enforced") + if not inherited and _profile_has_password(profile, protocol) and not password: + raise MailProfileError(f"{protocol.upper()} credentials must be supplied by this campaign or scope policy") + + +def assert_campaign_mail_policy_allows_json( + session: Session, + *, + tenant_id: str, + raw_json: dict[str, Any] | None, + campaign_id: str | None = None, + owner_user_id: str | None = None, + owner_group_id: str | None = None, +) -> None: + data = raw_json if isinstance(raw_json, dict) else {} + server = data.get("server") if isinstance(data.get("server"), dict) else {} + profile_id = server.get("mail_profile_id") if isinstance(server, dict) else None + if profile_id: + if campaign_id: + profile = ensure_mail_profile_allowed_for_campaign( + session, + tenant_id=tenant_id, + campaign_id=campaign_id, + profile_id=str(profile_id), + require_active=True, + ) + policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id) + _assert_profile_credentials_allowed_for_server(profile, policy, server) + return + profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=str(profile_id), require_active=True) + policy = effective_mail_profile_policy( + session, + tenant_id=tenant_id, + owner_user_id=owner_user_id, + owner_group_id=owner_group_id, + ) + if not _profile_allowed_for_context( + profile, + tenant_id=tenant_id, + policy=policy, + owner_user_id=owner_user_id, + owner_group_id=owner_group_id, + ): + raise MailProfileError("Mail-server profile is not allowed by the effective policy") + _assert_profile_credentials_allowed_for_server(profile, policy, server) + return + smtp = server.get("smtp") if isinstance(server, dict) and isinstance(server.get("smtp"), dict) else None + imap = server.get("imap") if isinstance(server, dict) and isinstance(server.get("imap"), dict) else None + assert_mail_policy_allows_transport( + session, + tenant_id=tenant_id, + smtp=smtp, + imap=imap, + campaign_id=campaign_id, + owner_user_id=owner_user_id, + owner_group_id=owner_group_id, + ) + + +def create_mail_server_profile( + session: Session, + *, + tenant_id: str, + user_id: str | None, + name: str, + slug: str | None, + description: str | None, + smtp: SmtpConfig, + imap: ImapConfig | None, + is_active: bool = True, + scope_type: str = "tenant", + scope_id: str | None = None, +) -> MailServerProfile: + clean_name = name.strip() + if not clean_name: + raise MailProfileError("Profile name cannot be empty") + profile_tenant_id, clean_scope_type, clean_scope_id = _scope_tuple_for_create( + session, + tenant_id=tenant_id, + scope_type=scope_type, + scope_id=scope_id, + user_id=user_id, + ) + _ensure_scope_allows_profile_creation(session, tenant_id=tenant_id, scope_type=clean_scope_type, scope_id=clean_scope_id) + if clean_scope_type == "campaign": + assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=smtp, imap=imap, campaign_id=clean_scope_id) + elif clean_scope_type == "user": + assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=smtp, imap=imap, owner_user_id=clean_scope_id) + elif clean_scope_type == "group": + assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=smtp, imap=imap, owner_group_id=clean_scope_id) + elif clean_scope_type == "tenant": + assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=smtp, imap=imap) + clean_slug = slugify_profile_name(slug or clean_name) + _ensure_unique_slug(session, scope_type=clean_scope_type, scope_id=clean_scope_id, slug=clean_slug) + smtp_payload, smtp_password, _ = _transport_payload(smtp) + imap_payload = None + imap_password = None + if imap is not None: + imap_payload, imap_password, _ = _transport_payload(imap) + profile = MailServerProfile( + tenant_id=profile_tenant_id, + scope_type=clean_scope_type, + scope_id=clean_scope_id, + name=clean_name, + slug=clean_slug, + description=description, + is_active=is_active, + smtp_config=smtp_payload, + smtp_password_encrypted=encrypt_secret(smtp_password), + imap_config=imap_payload, + imap_password_encrypted=encrypt_secret(imap_password), + created_by_user_id=user_id, + updated_by_user_id=user_id, + ) + session.add(profile) + session.flush() + return profile + + +def update_mail_server_profile( + session: Session, + profile: MailServerProfile, + *, + user_id: str | None, + tenant_id: str, + name: str | None = None, + slug: str | None = None, + description: str | None = None, + is_active: bool | None = None, + smtp: SmtpConfig | None = None, + imap: ImapConfig | None = None, + clear_imap: bool = False, +) -> MailServerProfile: + if name is not None: + clean_name = name.strip() + if not clean_name: + raise MailProfileError("Profile name cannot be empty") + profile.name = clean_name + if slug is not None: + clean_slug = slugify_profile_name(slug) + _ensure_unique_slug( + session, + scope_type=_profile_scope_type(profile), + scope_id=_profile_scope_id(profile), + slug=clean_slug, + profile_id=profile.id, + ) + profile.slug = clean_slug + if description is not None: + profile.description = description + if is_active is not None: + profile.is_active = is_active + + next_smtp = smtp or SmtpConfig.model_validate({**(profile.smtp_config or {}), "password": decrypt_secret(profile.smtp_password_encrypted)}) + if clear_imap: + next_imap = None + elif imap is not None: + next_imap = imap + elif profile.imap_config: + next_imap = ImapConfig.model_validate({**(profile.imap_config or {}), "password": decrypt_secret(profile.imap_password_encrypted)}) + else: + next_imap = None + + scope_type = _profile_scope_type(profile) + scope_id = _profile_scope_id(profile) + if scope_type == "campaign": + assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=next_smtp, imap=next_imap, campaign_id=scope_id) + elif scope_type == "user": + assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=next_smtp, imap=next_imap, owner_user_id=scope_id) + elif scope_type == "group": + assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=next_smtp, imap=next_imap, owner_group_id=scope_id) + elif scope_type == "tenant": + assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=next_smtp, imap=next_imap) + + if smtp is not None: + smtp_payload, smtp_password, supplied = _transport_payload(smtp) + profile.smtp_config = smtp_payload + if supplied: + profile.smtp_password_encrypted = encrypt_secret(smtp_password) + if clear_imap: + profile.imap_config = None + profile.imap_password_encrypted = None + elif imap is not None: + imap_payload, imap_password, supplied = _transport_payload(imap) + profile.imap_config = imap_payload + if supplied: + profile.imap_password_encrypted = encrypt_secret(imap_password) + profile.updated_by_user_id = user_id + session.add(profile) + session.flush() + return profile + + +def smtp_config_from_profile(profile: MailServerProfile) -> SmtpConfig: + payload = dict(profile.smtp_config or {}) + payload["password"] = decrypt_secret(profile.smtp_password_encrypted) + return SmtpConfig.model_validate(payload) + + +def imap_config_from_profile(profile: MailServerProfile) -> ImapConfig | None: + if not profile.imap_config: + return None + payload = dict(profile.imap_config or {}) + payload["password"] = decrypt_secret(profile.imap_password_encrypted) + return ImapConfig.model_validate(payload) + + +def inherit_profile_credentials(server: dict[str, Any], protocol: str) -> bool: + return _legacy_profile_credentials_inherited(server, protocol) + + +def _transport_credentials_from_server(server: dict[str, Any], protocol: str) -> tuple[str | None, str | None]: + config = server.get(protocol) + if not isinstance(config, dict): + return None, None + username = config.get("username") + password = config.get("password") + return (str(username) if username not in (None, "") else None, str(password) if password not in (None, "") else None) + + +def apply_campaign_credentials(payload: dict[str, Any], server: dict[str, Any], protocol: str) -> dict[str, Any]: + username, password = _transport_credentials_from_server(server, protocol) + payload["username"] = username + payload["password"] = password + return payload + + +def profile_response_payload(profile: MailServerProfile) -> dict[str, Any]: + return { + "id": profile.id, + "tenant_id": profile.tenant_id, + "scope_type": _profile_scope_type(profile), + "scope_id": _profile_scope_id(profile), + "name": profile.name, + "slug": profile.slug, + "description": profile.description, + "is_active": profile.is_active, + "smtp": dict(profile.smtp_config or {}), + "imap": dict(profile.imap_config or {}) if profile.imap_config else None, + "smtp_password_configured": bool(profile.smtp_password_encrypted), + "imap_password_configured": bool(profile.imap_password_encrypted), + "created_at": profile.created_at, + "updated_at": profile.updated_at, + } + + +def get_mail_profile_policy( + session: Session, + *, + tenant_id: str, + scope_type: str, + scope_id: str | None = None, +) -> dict[str, Any]: + clean_scope = _normalize_scope_type(scope_type) + if clean_scope == "system": + return _policy_from_settings(get_system_settings(session).settings or {}) + if clean_scope == "tenant": + tenant = session.get(Tenant, scope_id or tenant_id) + if tenant is None or tenant.id != tenant_id: + raise MailProfileError("Tenant policy not found") + return _policy_from_settings(tenant.settings or {}) + if not scope_id: + raise MailProfileError(f"{clean_scope.capitalize()} mail profile policy requires scope_id") + if clean_scope == "user": + user = session.get(User, scope_id) + if user is None or user.tenant_id != tenant_id: + raise MailProfileError("User policy not found") + return _policy_from_attr(user.mail_profile_policy) + if clean_scope == "group": + group = session.get(Group, scope_id) + if group is None or group.tenant_id != tenant_id: + raise MailProfileError("Group policy not found") + return _policy_from_attr(group.mail_profile_policy) + campaign = session.get(Campaign, scope_id) + if campaign is None or campaign.tenant_id != tenant_id: + raise MailProfileError("Campaign policy not found") + return _policy_from_attr(campaign.mail_profile_policy) + + +def _validate_policy_against_parent(parent: EffectiveMailProfilePolicy, normalized: dict[str, Any]) -> None: + for protocol in ("smtp", "imap"): + local = normalized.get(f"{protocol}_credentials") or {} + parent_credentials = _credential_policy_for_protocol(parent, protocol) + local_inherit = local.get("inherit") + if isinstance(local_inherit, bool) and not parent_credentials.allow_override and local_inherit != parent_credentials.inherit: + raise MailProfileError(f"{protocol.upper()} credential inheritance is locked by an ancestor policy") + local_allow_override = local.get("allow_override") + if local_allow_override is True and not parent_credentials.allow_override: + raise MailProfileError(f"{protocol.upper()} credential override cannot be re-enabled below a locked ancestor policy") + + +def set_mail_profile_policy( + session: Session, + *, + tenant_id: str, + scope_type: str, + scope_id: str | None = None, + policy: dict[str, Any] | None = None, +) -> dict[str, Any]: + clean_scope = _normalize_scope_type(scope_type) + normalized = normalize_mail_profile_policy(policy or {}) + if clean_scope != "system": + _validate_policy_against_parent( + parent_mail_profile_policy(session, tenant_id=tenant_id, scope_type=clean_scope, scope_id=scope_id or (tenant_id if clean_scope == "tenant" else None)), + normalized, + ) + if clean_scope == "system": + item = get_system_settings(session) + settings_payload = dict(item.settings or {}) + settings_payload[MAIL_PROFILE_POLICY_SETTINGS_KEY] = normalized + item.settings = settings_payload + session.add(item) + return normalized + if clean_scope == "tenant": + tenant = session.get(Tenant, scope_id or tenant_id) + if tenant is None or tenant.id != tenant_id: + raise MailProfileError("Tenant policy not found") + settings_payload = dict(tenant.settings or {}) + settings_payload[MAIL_PROFILE_POLICY_SETTINGS_KEY] = normalized + tenant.settings = settings_payload + session.add(tenant) + return normalized + if not scope_id: + raise MailProfileError(f"{clean_scope.capitalize()} mail profile policy requires scope_id") + if clean_scope == "user": + user = session.get(User, scope_id) + if user is None or user.tenant_id != tenant_id: + raise MailProfileError("User policy not found") + user.mail_profile_policy = normalized + session.add(user) + return normalized + if clean_scope == "group": + group = session.get(Group, scope_id) + if group is None or group.tenant_id != tenant_id: + raise MailProfileError("Group policy not found") + group.mail_profile_policy = normalized + session.add(group) + return normalized + campaign = session.get(Campaign, scope_id) + if campaign is None or campaign.tenant_id != tenant_id: + raise MailProfileError("Campaign policy not found") + campaign.mail_profile_policy = normalized + session.add(campaign) + return normalized + + +def materialize_campaign_mail_profile_config( + session: Session, + *, + tenant_id: str, + raw_json: dict[str, Any], + campaign_id: str | None = None, + owner_user_id: str | None = None, + owner_group_id: str | None = None, +) -> dict[str, Any]: + data = copy.deepcopy(raw_json if isinstance(raw_json, dict) else {}) + server = data.get("server") + if not isinstance(server, dict): + return data + profile_id = server.get("mail_profile_id") + if not profile_id: + assert_campaign_mail_policy_allows_json( + session, + tenant_id=tenant_id, + raw_json=data, + campaign_id=campaign_id, + owner_user_id=owner_user_id, + owner_group_id=owner_group_id, + ) + return data + if campaign_id: + profile = ensure_mail_profile_allowed_for_campaign(session, tenant_id=tenant_id, campaign_id=campaign_id, profile_id=str(profile_id), require_active=True) + else: + profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=str(profile_id), require_active=True) + assert_campaign_mail_policy_allows_json( + session, + tenant_id=tenant_id, + raw_json=data, + owner_user_id=owner_user_id, + owner_group_id=owner_group_id, + ) + policy = effective_mail_profile_policy( + session, + tenant_id=tenant_id, + campaign_id=campaign_id, + owner_user_id=owner_user_id, + owner_group_id=owner_group_id, + ) + _assert_profile_credentials_allowed_for_server(profile, policy, server) + resolved_server = dict(server) + smtp_payload = smtp_config_from_profile(profile).model_dump(mode="json") + if not profile_credentials_inherited_for_server(policy, server, "smtp"): + smtp_payload = apply_campaign_credentials(smtp_payload, server, "smtp") + resolved_server["smtp"] = smtp_payload + imap = imap_config_from_profile(profile) + if imap is not None: + imap_payload = imap.model_dump(mode="json") + if not profile_credentials_inherited_for_server(policy, server, "imap"): + imap_payload = apply_campaign_credentials(imap_payload, server, "imap") + resolved_server["imap"] = imap_payload + else: + resolved_server.pop("imap", None) + data["server"] = resolved_server + return data + + +def mail_profile_id_from_campaign_json(raw_json: dict[str, Any] | None) -> str | None: + server = raw_json.get("server") if isinstance(raw_json, dict) else None + value = server.get("mail_profile_id") if isinstance(server, dict) else None + return str(value) if value else None + + +def group_ids_for_user(session: Session, *, tenant_id: str, user_id: str) -> list[str]: + return [ + row.group_id + for row in session.query(UserGroupMembership).filter( + UserGroupMembership.tenant_id == tenant_id, + UserGroupMembership.user_id == user_id, + ) + ] diff --git a/src/govoplan_mail/backend/manifest.py b/src/govoplan_mail/backend/manifest.py new file mode 100644 index 0000000..70f2eb5 --- /dev/null +++ b/src/govoplan_mail/backend/manifest.py @@ -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 + diff --git a/src/govoplan_mail/backend/migrations/__init__.py b/src/govoplan_mail/backend/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_mail/backend/migrations/versions/__init__.py b/src/govoplan_mail/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_mail/backend/router.py b/src/govoplan_mail/backend/router.py new file mode 100644 index 0000000..d92cacd --- /dev/null +++ b/src/govoplan_mail/backend/router.py @@ -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__}, + ) diff --git a/src/govoplan_mail/backend/runtime.py b/src/govoplan_mail/backend/runtime.py new file mode 100644 index 0000000..295b1e5 --- /dev/null +++ b/src/govoplan_mail/backend/runtime.py @@ -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() diff --git a/src/govoplan_mail/backend/schemas.py b/src/govoplan_mail/backend/schemas.py new file mode 100644 index 0000000..668fb79 --- /dev/null +++ b/src/govoplan_mail/backend/schemas.py @@ -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) diff --git a/src/govoplan_mail/backend/sending/__init__.py b/src/govoplan_mail/backend/sending/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_mail/backend/sending/imap.py b/src/govoplan_mail/backend/sending/imap.py new file mode 100644 index 0000000..502b2ad --- /dev/null +++ b/src/govoplan_mail/backend/sending/imap.py @@ -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[^)]*)\)\s+' + r'(?P"(?:[^"\\]|\\.)*"|NIL|[^\s]+)\s+' + r'(?P.+?)\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 diff --git a/src/govoplan_mail/backend/sending/rate_limit.py b/src/govoplan_mail/backend/sending/rate_limit.py new file mode 100644 index 0000000..168ae00 --- /dev/null +++ b/src/govoplan_mail/backend/sending/rate_limit.py @@ -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) diff --git a/src/govoplan_mail/backend/sending/smtp.py b/src/govoplan_mail/backend/sending/smtp.py new file mode 100644 index 0000000..6325b31 --- /dev/null +++ b/src/govoplan_mail/backend/sending/smtp.py @@ -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, + ) diff --git a/src/govoplan_mail/py.typed b/src/govoplan_mail/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..7488bce --- /dev/null +++ b/webui/package.json @@ -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" + } +} diff --git a/webui/src/api/client.ts b/webui/src/api/client.ts new file mode 100644 index 0000000..c1673ff --- /dev/null +++ b/webui/src/api/client.ts @@ -0,0 +1 @@ +export { apiFetch, apiUrl, authHeaders, csrfToken, apiDownload } from "@govoplan/core-webui"; diff --git a/webui/src/api/mail.ts b/webui/src/api/mail.ts new file mode 100644 index 0000000..5db63b7 --- /dev/null +++ b/webui/src/api/mail.ts @@ -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; +}; + +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; +}; + + +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>; + +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 { + 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(settings, `/api/v1/mail/profiles${suffix}`); + return response.profiles ?? []; +} + +export async function createMailServerProfile(settings: ApiSettings, payload: MailServerProfilePayload): Promise { + return apiFetch(settings, "/api/v1/mail/profiles", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export type MailServerProfileUpdatePayload = Partial & { clear_imap?: boolean }; + +export async function updateMailServerProfile(settings: ApiSettings, profileId: string, payload: MailServerProfileUpdatePayload): Promise { + return apiFetch(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}`, { + method: "PATCH", + body: JSON.stringify(payload) + }); +} + +export async function deactivateMailServerProfile(settings: ApiSettings, profileId: string): Promise { + return apiFetch(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}`, { method: "DELETE" }); +} + +export async function getMailProfilePolicy( + settings: ApiSettings, + scopeType: MailProfileScope, + scopeId?: string | null, + campaignId?: string | null +): Promise { + 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(settings, `/api/v1/mail/policies/${encodeURIComponent(scopeType)}${suffix}`); +} + +export async function updateMailProfilePolicy( + settings: ApiSettings, + scopeType: MailProfileScope, + policy: MailProfilePolicy, + scopeId?: string | null +): Promise { + const params = new URLSearchParams(); + if (scopeId) params.set("scope_id", scopeId); + const suffix = params.toString() ? `?${params.toString()}` : ""; + return apiFetch(settings, `/api/v1/mail/policies/${encodeURIComponent(scopeType)}${suffix}`, { + method: "PUT", + body: JSON.stringify({ policy }) + }); +} + +export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise { + return apiFetch(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-smtp`, { method: "POST" }); +} + +export async function testMailProfileImap(settings: ApiSettings, profileId: string): Promise { + return apiFetch(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-imap`, { method: "POST" }); +} + +export async function listMailProfileImapFolders(settings: ApiSettings, profileId: string): Promise { + return apiFetch(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`, { method: "POST" }); +} + +export async function testSmtpSettings( + settings: ApiSettings, + payload: MailSmtpTestPayload +): Promise { + return apiFetch(settings, "/api/v1/mail/test-smtp", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export async function testImapSettings( + settings: ApiSettings, + payload: MailImapTestPayload +): Promise { + return apiFetch(settings, "/api/v1/mail/test-imap", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export async function listImapFolders( + settings: ApiSettings, + payload: MailImapTestPayload +): Promise { + return apiFetch(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; + 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 { + const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : ""; + return apiFetch(settings, `/api/v1/dev/mailbox/messages${suffix}`); +} + +export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise { + return apiFetch(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) + }); +} diff --git a/webui/src/components/Button.tsx b/webui/src/components/Button.tsx new file mode 100644 index 0000000..710440c --- /dev/null +++ b/webui/src/components/Button.tsx @@ -0,0 +1,2 @@ +import { Button } from "@govoplan/core-webui"; +export default Button; diff --git a/webui/src/components/Card.tsx b/webui/src/components/Card.tsx new file mode 100644 index 0000000..e1eeb17 --- /dev/null +++ b/webui/src/components/Card.tsx @@ -0,0 +1,2 @@ +import { Card } from "@govoplan/core-webui"; +export default Card; diff --git a/webui/src/components/ConfirmDialog.tsx b/webui/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..eeb657c --- /dev/null +++ b/webui/src/components/ConfirmDialog.tsx @@ -0,0 +1,2 @@ +import { ConfirmDialog } from "@govoplan/core-webui"; +export default ConfirmDialog; diff --git a/webui/src/components/Dialog.tsx b/webui/src/components/Dialog.tsx new file mode 100644 index 0000000..e0b9e8b --- /dev/null +++ b/webui/src/components/Dialog.tsx @@ -0,0 +1,2 @@ +import { Dialog } from "@govoplan/core-webui"; +export default Dialog; diff --git a/webui/src/components/DismissibleAlert.tsx b/webui/src/components/DismissibleAlert.tsx new file mode 100644 index 0000000..09ad137 --- /dev/null +++ b/webui/src/components/DismissibleAlert.tsx @@ -0,0 +1,2 @@ +import { DismissibleAlert } from "@govoplan/core-webui"; +export default DismissibleAlert; diff --git a/webui/src/components/FormField.tsx b/webui/src/components/FormField.tsx new file mode 100644 index 0000000..59b6792 --- /dev/null +++ b/webui/src/components/FormField.tsx @@ -0,0 +1,2 @@ +import { FormField } from "@govoplan/core-webui"; +export default FormField; diff --git a/webui/src/components/LoadingFrame.tsx b/webui/src/components/LoadingFrame.tsx new file mode 100644 index 0000000..c007f26 --- /dev/null +++ b/webui/src/components/LoadingFrame.tsx @@ -0,0 +1,2 @@ +import { LoadingFrame } from "@govoplan/core-webui"; +export default LoadingFrame; diff --git a/webui/src/components/LoadingIndicator.tsx b/webui/src/components/LoadingIndicator.tsx new file mode 100644 index 0000000..e2e63cc --- /dev/null +++ b/webui/src/components/LoadingIndicator.tsx @@ -0,0 +1,2 @@ +import { LoadingIndicator } from "@govoplan/core-webui"; +export default LoadingIndicator; diff --git a/webui/src/components/MetricCard.tsx b/webui/src/components/MetricCard.tsx new file mode 100644 index 0000000..5af7d1f --- /dev/null +++ b/webui/src/components/MetricCard.tsx @@ -0,0 +1,2 @@ +import { MetricCard } from "@govoplan/core-webui"; +export default MetricCard; diff --git a/webui/src/components/PageTitle.tsx b/webui/src/components/PageTitle.tsx new file mode 100644 index 0000000..2a58990 --- /dev/null +++ b/webui/src/components/PageTitle.tsx @@ -0,0 +1,2 @@ +import { PageTitle } from "@govoplan/core-webui"; +export default PageTitle; diff --git a/webui/src/components/StatusBadge.tsx b/webui/src/components/StatusBadge.tsx new file mode 100644 index 0000000..13347b8 --- /dev/null +++ b/webui/src/components/StatusBadge.tsx @@ -0,0 +1,2 @@ +import { StatusBadge } from "@govoplan/core-webui"; +export default StatusBadge; diff --git a/webui/src/components/Stepper.tsx b/webui/src/components/Stepper.tsx new file mode 100644 index 0000000..fc27f21 --- /dev/null +++ b/webui/src/components/Stepper.tsx @@ -0,0 +1,2 @@ +import { Stepper } from "@govoplan/core-webui"; +export default Stepper; diff --git a/webui/src/components/ToggleSwitch.tsx b/webui/src/components/ToggleSwitch.tsx new file mode 100644 index 0000000..cae981b --- /dev/null +++ b/webui/src/components/ToggleSwitch.tsx @@ -0,0 +1,2 @@ +import { ToggleSwitch } from "@govoplan/core-webui"; +export default ToggleSwitch; diff --git a/webui/src/components/email/EmailAddressInput.tsx b/webui/src/components/email/EmailAddressInput.tsx new file mode 100644 index 0000000..40f5009 --- /dev/null +++ b/webui/src/components/email/EmailAddressInput.tsx @@ -0,0 +1,2 @@ +import { EmailAddressInput } from "@govoplan/core-webui"; +export default EmailAddressInput; diff --git a/webui/src/components/help/FieldLabel.tsx b/webui/src/components/help/FieldLabel.tsx new file mode 100644 index 0000000..b205494 --- /dev/null +++ b/webui/src/components/help/FieldLabel.tsx @@ -0,0 +1,2 @@ +import { FieldLabel } from "@govoplan/core-webui"; +export default FieldLabel; diff --git a/webui/src/components/help/InlineHelp.tsx b/webui/src/components/help/InlineHelp.tsx new file mode 100644 index 0000000..d2cb6bf --- /dev/null +++ b/webui/src/components/help/InlineHelp.tsx @@ -0,0 +1,2 @@ +import { InlineHelp } from "@govoplan/core-webui"; +export default InlineHelp; diff --git a/webui/src/components/table/DataGrid.tsx b/webui/src/components/table/DataGrid.tsx new file mode 100644 index 0000000..ec10c13 --- /dev/null +++ b/webui/src/components/table/DataGrid.tsx @@ -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; diff --git a/webui/src/features/mail/MailProfileManagement.tsx b/webui/src/features/mail/MailProfileManagement.tsx new file mode 100644 index 0000000..0c15125 --- /dev/null +++ b/webui/src/features/mail/MailProfileManagement.tsx @@ -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; +}; + +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 = { + 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([]); + const [selectedTargetId, setSelectedTargetId] = useState(scopeId || targetOptions[0]?.id || ""); + const [editing, setEditing] = useState(null); + const [deactivating, setDeactivating] = useState(null); + const [draft, setDraft] = useState(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[]>(() => [ + { id: "name", header: "Profile", width: "minmax(220px, 1fr)", sticky: "start", resizable: true, sortable: true, filterable: true, value: (profile) => `${profile.name} ${profile.slug}`, render: (profile) =>
{profile.name}
{profile.slug}
}, + { 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) => }, + { id: "imap", header: "IMAP", width: 220, sortable: true, filterable: true, value: (profile) => profile.imap ? transportLabel(profile.imap) : "Not configured", render: (profile) => }, + { id: "status", header: "Status", width: 110, sortable: true, filterable: true, value: (profile) => profile.is_active ? "Active" : "Inactive", render: (profile) => {profile.is_active ? "Active" : "Inactive"} }, + { id: "actions", header: "Actions", width: 145, sticky: "end", align: "right", render: (profile) =>
+ + +
} + ], [busy, canWriteProfiles]); + + return ( +
+ {targetOptions.length > 0 && !scopeId && ( + +
+ + + +
+
+ )} + + + + + + +
+ } + > +
+ profile.id} + emptyText={scopeReady ? "No profiles in this scope." : `Select a ${targetLabel.toLowerCase()} first.`} + /> +
+ + + {error && {error}} + {success && {success}} + + !busy && setEditing(null)} + className="admin-dialog admin-dialog-wide mail-profile-dialog" + closeDisabled={busy} + footer={<>} + > + + + + setDeactivating(null)} + onConfirm={() => void deactivateProfile()} + /> + + ); +} + +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(blankPolicy); + const [effectivePolicy, setEffectivePolicy] = useState(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) { + 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) { + 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 ( + + + + + } + > +
+ {description &&

{description}

} + {error && {error}} + {success && {success}} + +
+
+

Profile allow-list

+ +
+

{selectedProfileIds.size === 0 ? "No local profile allow-list is set." : `${selectedProfileIds.size} profile(s) allowed by this scope.`}

+
+ {candidateProfiles.map((profile) => ( + + ))} + {candidateProfiles.length === 0 &&

No profiles are visible for this policy scope.

} +
+
+ +
+

Lower-level profile definitions

+
+ setFlag("allow_user_profiles", value)} /> + setFlag("allow_group_profiles", value)} /> + setFlag("allow_campaign_profiles", value)} /> +
+
+ +
+

Credential inheritance

+
+ setCredentialPolicy("smtp", { inherit: valueToCredentialInheritance(value) })} /> + setCredentialPolicy("smtp", { allow_override: valueToCredentialOverride(value) })} /> + setCredentialPolicy("imap", { inherit: valueToCredentialInheritance(value) })} /> + setCredentialPolicy("imap", { allow_override: valueToCredentialOverride(value) })} /> +
+
+ +
+

Wildcard rules

+
+
+

Whitelist

+ {mailProfilePatternKeys.map((key) => setPattern("whitelist", key, text)} />)} +
+
+

Blacklist

+ {mailProfilePatternKeys.map((key) => setPattern("blacklist", key, text)} />)} +
+
+
+ + {effectivePolicy && ( +
+

Effective campaign policy

+
+
Profiles{effectiveProfileLabel(effectivePolicy.allowed_profile_ids)}
+
User profiles{effectivePolicy.allow_user_profiles ? "Allowed" : "Blocked"}
+
Group profiles{effectivePolicy.allow_group_profiles ? "Allowed" : "Blocked"}
+
Campaign profiles{effectivePolicy.allow_campaign_profiles ? "Allowed" : "Blocked"}
+
SMTP credentials{credentialPolicyLabel(effectivePolicy.smtp_credentials)}
+
IMAP credentials{credentialPolicyLabel(effectivePolicy.imap_credentials)}
+
+
+ )} +
+
+ ); +} + +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 ( +
+
+ setDraft({ ...draft, name: event.target.value })} /> + setDraft({ ...draft, slug: event.target.value })} placeholder="Generated from name" /> + +