initial commit after split
This commit is contained in:
14
README.md
14
README.md
@@ -1,2 +1,16 @@
|
|||||||
# govoplan-mail
|
# govoplan-mail
|
||||||
|
|
||||||
|
GovOPlaN Mail module.
|
||||||
|
|
||||||
|
This repository owns the Mail module manifest, backend router, mail schemas, mail profile ORM model, SMTP/IMAP profile services, SMTP/IMAP adapters, development mailbox backend, and mail profile WebUI package. The remaining `app.*` imports are transitional core adapters for auth, governance settings, secrets, and legacy access models.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Install through the core development environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan-core
|
||||||
|
./.venv/bin/python -m pip install -r requirements-dev.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend module is registered through the `govoplan.modules` entry point `mail`. The frontend package is `@govoplan/mail-webui`.
|
||||||
|
|||||||
27
pyproject.toml
Normal file
27
pyproject.toml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "govoplan-mail"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "GovOPlaN mail module with backend and WebUI integration."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
license = { file = "LICENSE" }
|
||||||
|
authors = [{ name = "GovOPlaN" }]
|
||||||
|
dependencies = [
|
||||||
|
"govoplan-core>=0.1.0",
|
||||||
|
"pydantic>=2,<3",
|
||||||
|
"redis>=5,<6",
|
||||||
|
"SQLAlchemy>=2,<3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
govoplan_mail = ["py.typed"]
|
||||||
|
|
||||||
|
[project.entry-points."govoplan.modules"]
|
||||||
|
mail = "govoplan_mail.backend.manifest:get_manifest"
|
||||||
1
src/govoplan_mail/__init__.py
Normal file
1
src/govoplan_mail/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""govoplan-mail module package."""
|
||||||
1
src/govoplan_mail/backend/__init__.py
Normal file
1
src/govoplan_mail/backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Backend integration for this GovOPlaN module."""
|
||||||
35
src/govoplan_mail/backend/config.py
Normal file
35
src/govoplan_mail/backend/config.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class StrictModel(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TransportSecurity(StrEnum):
|
||||||
|
PLAIN = "plain"
|
||||||
|
TLS = "tls"
|
||||||
|
STARTTLS = "starttls"
|
||||||
|
|
||||||
|
|
||||||
|
class SmtpConfig(StrictModel):
|
||||||
|
host: str | None = None
|
||||||
|
port: int | None = Field(default=None, ge=1, le=65535)
|
||||||
|
username: str | None = None
|
||||||
|
password: str | None = None
|
||||||
|
security: TransportSecurity = TransportSecurity.STARTTLS
|
||||||
|
timeout_seconds: int = Field(default=30, ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class ImapConfig(StrictModel):
|
||||||
|
enabled: bool = False
|
||||||
|
host: str | None = None
|
||||||
|
port: int | None = Field(default=None, ge=1, le=65535)
|
||||||
|
username: str | None = None
|
||||||
|
password: str | None = None
|
||||||
|
security: TransportSecurity = TransportSecurity.TLS
|
||||||
|
sent_folder: str = "auto"
|
||||||
|
timeout_seconds: int = Field(default=30, ge=1)
|
||||||
0
src/govoplan_mail/backend/db/__init__.py
Normal file
0
src/govoplan_mail/backend/db/__init__.py
Normal file
40
src/govoplan_mail/backend/db/models.py
Normal file
40
src/govoplan_mail/backend/db/models.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, ForeignKey, Index, JSON, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from govoplan_core.db.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
def new_uuid() -> str:
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
class MailServerProfile(Base, TimestampMixin):
|
||||||
|
__tablename__ = "mail_server_profiles"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "slug", name="uq_mail_server_profiles_tenant_slug"),
|
||||||
|
Index("ix_mail_server_profiles_scope", "scope_type", "scope_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||||
|
scope_type: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False, index=True)
|
||||||
|
scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
||||||
|
smtp_config: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
smtp_password_encrypted: Mapped[str | None] = mapped_column(Text)
|
||||||
|
imap_config: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
|
imap_password_encrypted: Mapped[str | None] = mapped_column(Text)
|
||||||
|
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
|
||||||
|
tenant: Mapped["Tenant"] = relationship()
|
||||||
|
|
||||||
|
|
||||||
0
src/govoplan_mail/backend/dev/__init__.py
Normal file
0
src/govoplan_mail/backend/dev/__init__.py
Normal file
327
src/govoplan_mail/backend/dev/mock_mailbox.py
Normal file
327
src/govoplan_mail/backend/dev/mock_mailbox.py
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from email import policy
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from email.parser import BytesParser
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from govoplan_mail.backend.runtime import settings
|
||||||
|
|
||||||
|
MOCK_SMTP_HOSTS = {"mock", "mock.smtp", "mock.smtp.local", "mock-mail", "__mock_smtp__"}
|
||||||
|
MOCK_IMAP_HOSTS = {"mock", "mock.imap", "mock.imap.local", "mock-mail", "__mock_imap__"}
|
||||||
|
MOCK_IMAP_FOLDERS = [
|
||||||
|
{"name": "INBOX", "flags": []},
|
||||||
|
{"name": "Sent", "flags": ["\\Sent"]},
|
||||||
|
{"name": "Drafts", "flags": ["\\Drafts"]},
|
||||||
|
{"name": "Trash", "flags": ["\\Trash"]},
|
||||||
|
{"name": "Archive", "flags": ["\\Archive"]},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class MockDeliveryRecord:
|
||||||
|
id: str
|
||||||
|
kind: str
|
||||||
|
created_at: str
|
||||||
|
envelope_from: str | None
|
||||||
|
envelope_recipients: list[str]
|
||||||
|
subject: str | None
|
||||||
|
from_header: str | None
|
||||||
|
to_header: str | None
|
||||||
|
cc_header: str | None
|
||||||
|
bcc_header: str | None
|
||||||
|
message_id: str | None
|
||||||
|
size_bytes: int
|
||||||
|
body_preview: str | None
|
||||||
|
attachment_count: int
|
||||||
|
folder: str | None = None
|
||||||
|
smtp_host: str | None = None
|
||||||
|
imap_host: str | None = None
|
||||||
|
raw_filename: str | None = None
|
||||||
|
headers: dict[str, str] | None = None
|
||||||
|
attachments: list[dict[str, Any]] | None = None
|
||||||
|
|
||||||
|
def as_dict(self, *, include_raw: bool = False) -> dict[str, Any]:
|
||||||
|
data = {
|
||||||
|
"id": self.id,
|
||||||
|
"kind": self.kind,
|
||||||
|
"created_at": self.created_at,
|
||||||
|
"envelope_from": self.envelope_from,
|
||||||
|
"envelope_recipients": self.envelope_recipients,
|
||||||
|
"subject": self.subject,
|
||||||
|
"from_header": self.from_header,
|
||||||
|
"to_header": self.to_header,
|
||||||
|
"cc_header": self.cc_header,
|
||||||
|
"bcc_header": self.bcc_header,
|
||||||
|
"message_id": self.message_id,
|
||||||
|
"size_bytes": self.size_bytes,
|
||||||
|
"body_preview": self.body_preview,
|
||||||
|
"attachment_count": self.attachment_count,
|
||||||
|
"folder": self.folder,
|
||||||
|
"smtp_host": self.smtp_host,
|
||||||
|
"imap_host": self.imap_host,
|
||||||
|
"raw_filename": self.raw_filename,
|
||||||
|
"headers": self.headers or {},
|
||||||
|
"attachments": self.attachments or [],
|
||||||
|
}
|
||||||
|
if include_raw:
|
||||||
|
data["raw_eml"] = read_raw_message(self.id)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _base_dir() -> Path:
|
||||||
|
path = Path(settings.mock_mailbox_dir)
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
(path / "messages").mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _message_dir() -> Path:
|
||||||
|
return _base_dir() / "messages"
|
||||||
|
|
||||||
|
|
||||||
|
def _failure_path() -> Path:
|
||||||
|
return _base_dir() / "failures.json"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_mock_host(host: str | None) -> str:
|
||||||
|
return (host or "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def is_mock_smtp_host(host: str | None) -> bool:
|
||||||
|
return normalize_mock_host(host) in MOCK_SMTP_HOSTS
|
||||||
|
|
||||||
|
|
||||||
|
def is_mock_imap_host(host: str | None) -> bool:
|
||||||
|
return normalize_mock_host(host) in MOCK_IMAP_HOSTS
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _message_to_bytes(message: EmailMessage | bytes) -> bytes:
|
||||||
|
if isinstance(message, bytes):
|
||||||
|
return message
|
||||||
|
return message.as_bytes(policy=policy.SMTP)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_message(raw: bytes) -> EmailMessage:
|
||||||
|
return BytesParser(policy=policy.default).parsebytes(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _header_text(message: EmailMessage, name: str) -> str | None:
|
||||||
|
value = message.get(name)
|
||||||
|
return str(value) if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _body_preview(message: EmailMessage) -> str | None:
|
||||||
|
body = None
|
||||||
|
try:
|
||||||
|
if message.is_multipart():
|
||||||
|
body = message.get_body(preferencelist=("plain", "html"))
|
||||||
|
else:
|
||||||
|
body = message
|
||||||
|
if body is None:
|
||||||
|
return None
|
||||||
|
content = body.get_content()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
text = re.sub(r"\s+", " ", str(content)).strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
return text[:600]
|
||||||
|
|
||||||
|
|
||||||
|
def _attachment_summaries(message: EmailMessage) -> list[dict[str, Any]]:
|
||||||
|
attachments: list[dict[str, Any]] = []
|
||||||
|
for part in message.iter_attachments():
|
||||||
|
payload = part.get_payload(decode=True) or b""
|
||||||
|
attachments.append(
|
||||||
|
{
|
||||||
|
"filename": part.get_filename(),
|
||||||
|
"content_type": part.get_content_type(),
|
||||||
|
"size_bytes": len(payload),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return attachments
|
||||||
|
|
||||||
|
|
||||||
|
def _headers(message: EmailMessage) -> dict[str, str]:
|
||||||
|
return {str(key): str(value) for key, value in message.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _record_from_raw(
|
||||||
|
raw: bytes,
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
envelope_from: str | None = None,
|
||||||
|
envelope_recipients: list[str] | None = None,
|
||||||
|
folder: str | None = None,
|
||||||
|
smtp_host: str | None = None,
|
||||||
|
imap_host: str | None = None,
|
||||||
|
) -> MockDeliveryRecord:
|
||||||
|
message_id = uuid4().hex
|
||||||
|
raw_filename = f"{message_id}.eml"
|
||||||
|
json_filename = f"{message_id}.json"
|
||||||
|
message = _parse_message(raw)
|
||||||
|
attachments = _attachment_summaries(message)
|
||||||
|
record = MockDeliveryRecord(
|
||||||
|
id=message_id,
|
||||||
|
kind=kind,
|
||||||
|
created_at=_now_iso(),
|
||||||
|
envelope_from=envelope_from,
|
||||||
|
envelope_recipients=list(envelope_recipients or []),
|
||||||
|
subject=_header_text(message, "Subject"),
|
||||||
|
from_header=_header_text(message, "From"),
|
||||||
|
to_header=_header_text(message, "To"),
|
||||||
|
cc_header=_header_text(message, "Cc"),
|
||||||
|
bcc_header=_header_text(message, "Bcc"),
|
||||||
|
message_id=_header_text(message, "Message-ID"),
|
||||||
|
size_bytes=len(raw),
|
||||||
|
body_preview=_body_preview(message),
|
||||||
|
attachment_count=len(attachments),
|
||||||
|
folder=folder,
|
||||||
|
smtp_host=smtp_host,
|
||||||
|
imap_host=imap_host,
|
||||||
|
raw_filename=raw_filename,
|
||||||
|
headers=_headers(message),
|
||||||
|
attachments=attachments,
|
||||||
|
)
|
||||||
|
message_dir = _message_dir()
|
||||||
|
(message_dir / raw_filename).write_bytes(raw)
|
||||||
|
(message_dir / json_filename).write_text(json.dumps(record.as_dict(), indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def record_smtp_delivery(
|
||||||
|
message: EmailMessage | bytes,
|
||||||
|
*,
|
||||||
|
envelope_from: str,
|
||||||
|
envelope_recipients: list[str],
|
||||||
|
smtp_host: str | None = None,
|
||||||
|
) -> MockDeliveryRecord:
|
||||||
|
return _record_from_raw(
|
||||||
|
_message_to_bytes(message),
|
||||||
|
kind="smtp",
|
||||||
|
envelope_from=envelope_from,
|
||||||
|
envelope_recipients=envelope_recipients,
|
||||||
|
smtp_host=smtp_host,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def record_imap_append(
|
||||||
|
message_bytes: bytes,
|
||||||
|
*,
|
||||||
|
folder: str,
|
||||||
|
imap_host: str | None = None,
|
||||||
|
) -> MockDeliveryRecord:
|
||||||
|
return _record_from_raw(message_bytes, kind="imap_append", folder=folder, imap_host=imap_host)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_record(record_id: str) -> dict[str, Any] | None:
|
||||||
|
path = _message_dir() / f"{record_id}.json"
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def list_records(*, kind: str | None = None, limit: int = 100) -> list[dict[str, Any]]:
|
||||||
|
records: list[dict[str, Any]] = []
|
||||||
|
for path in _message_dir().glob("*.json"):
|
||||||
|
try:
|
||||||
|
record = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if kind and record.get("kind") != kind:
|
||||||
|
continue
|
||||||
|
records.append(record)
|
||||||
|
records.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True)
|
||||||
|
return records[: max(1, min(limit, 500))]
|
||||||
|
|
||||||
|
|
||||||
|
def get_record(record_id: str, *, include_raw: bool = True) -> dict[str, Any] | None:
|
||||||
|
record = _load_record(record_id)
|
||||||
|
if record and include_raw:
|
||||||
|
record["raw_eml"] = read_raw_message(record_id)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def read_raw_message(record_id: str) -> str | None:
|
||||||
|
record = _load_record(record_id)
|
||||||
|
if not record:
|
||||||
|
return None
|
||||||
|
raw_filename = record.get("raw_filename")
|
||||||
|
if not raw_filename:
|
||||||
|
return None
|
||||||
|
raw_path = _message_dir() / str(raw_filename)
|
||||||
|
if not raw_path.exists():
|
||||||
|
return None
|
||||||
|
return raw_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def clear_records() -> int:
|
||||||
|
message_dir = _message_dir()
|
||||||
|
count = len(list(message_dir.glob("*.json")))
|
||||||
|
if message_dir.exists():
|
||||||
|
shutil.rmtree(message_dir)
|
||||||
|
message_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def get_failures() -> dict[str, Any]:
|
||||||
|
path = _failure_path()
|
||||||
|
if not path.exists():
|
||||||
|
return {
|
||||||
|
"fail_next_smtp": False,
|
||||||
|
"fail_next_imap": False,
|
||||||
|
"smtp_reject_recipients_containing": None,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
data = {}
|
||||||
|
return {
|
||||||
|
"fail_next_smtp": bool(data.get("fail_next_smtp")),
|
||||||
|
"fail_next_imap": bool(data.get("fail_next_imap")),
|
||||||
|
"smtp_reject_recipients_containing": data.get("smtp_reject_recipients_containing") or None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def set_failures(*, fail_next_smtp: bool | None = None, fail_next_imap: bool | None = None, smtp_reject_recipients_containing: str | None = None) -> dict[str, Any]:
|
||||||
|
current = get_failures()
|
||||||
|
if fail_next_smtp is not None:
|
||||||
|
current["fail_next_smtp"] = fail_next_smtp
|
||||||
|
if fail_next_imap is not None:
|
||||||
|
current["fail_next_imap"] = fail_next_imap
|
||||||
|
current["smtp_reject_recipients_containing"] = smtp_reject_recipients_containing or None
|
||||||
|
_failure_path().write_text(json.dumps(current, indent=2), encoding="utf-8")
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def consume_fail_next_smtp() -> bool:
|
||||||
|
current = get_failures()
|
||||||
|
if current.get("fail_next_smtp"):
|
||||||
|
current["fail_next_smtp"] = False
|
||||||
|
_failure_path().write_text(json.dumps(current, indent=2), encoding="utf-8")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def consume_fail_next_imap() -> bool:
|
||||||
|
current = get_failures()
|
||||||
|
if current.get("fail_next_imap"):
|
||||||
|
current["fail_next_imap"] = False
|
||||||
|
_failure_path().write_text(json.dumps(current, indent=2), encoding="utf-8")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
95
src/govoplan_mail/backend/dev_router.py
Normal file
95
src/govoplan_mail/backend/dev_router.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from govoplan_core.auth.dependencies import ApiPrincipal, require_scope
|
||||||
|
from govoplan_mail.backend.dev.mock_mailbox import (
|
||||||
|
clear_records,
|
||||||
|
get_failures,
|
||||||
|
get_record,
|
||||||
|
list_records,
|
||||||
|
set_failures,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/dev/mailbox", tags=["dev-mailbox"])
|
||||||
|
|
||||||
|
|
||||||
|
class MockMailboxListResponse(BaseModel):
|
||||||
|
messages: list[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class MockMailboxMessageResponse(BaseModel):
|
||||||
|
message: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class MockMailboxClearResponse(BaseModel):
|
||||||
|
deleted_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class MockMailboxFailureConfig(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
fail_next_smtp: bool | None = None
|
||||||
|
fail_next_imap: bool | None = None
|
||||||
|
smtp_reject_recipients_containing: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class MockMailboxFailureResponse(BaseModel):
|
||||||
|
config: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/messages", response_model=MockMailboxListResponse)
|
||||||
|
def list_mock_mailbox_messages(
|
||||||
|
kind: str | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||||
|
):
|
||||||
|
"""List messages captured by the integrated development mail sandbox."""
|
||||||
|
|
||||||
|
del principal
|
||||||
|
return MockMailboxListResponse(messages=list_records(kind=kind, limit=limit))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/messages/{message_id}", response_model=MockMailboxMessageResponse)
|
||||||
|
def get_mock_mailbox_message(
|
||||||
|
message_id: str,
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||||
|
):
|
||||||
|
del principal
|
||||||
|
message = get_record(message_id, include_raw=True)
|
||||||
|
if not message:
|
||||||
|
raise HTTPException(status_code=404, detail="Mock mailbox message not found")
|
||||||
|
return MockMailboxMessageResponse(message=message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/messages", response_model=MockMailboxClearResponse)
|
||||||
|
def clear_mock_mailbox_messages(
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send_test")),
|
||||||
|
):
|
||||||
|
del principal
|
||||||
|
return MockMailboxClearResponse(deleted_count=clear_records())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/failures", response_model=MockMailboxFailureResponse)
|
||||||
|
def get_mock_failure_config(
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||||
|
):
|
||||||
|
del principal
|
||||||
|
return MockMailboxFailureResponse(config=get_failures())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/failures", response_model=MockMailboxFailureResponse)
|
||||||
|
def update_mock_failure_config(
|
||||||
|
payload: MockMailboxFailureConfig,
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send_test")),
|
||||||
|
):
|
||||||
|
del principal
|
||||||
|
config = set_failures(
|
||||||
|
fail_next_smtp=payload.fail_next_smtp,
|
||||||
|
fail_next_imap=payload.fail_next_imap,
|
||||||
|
smtp_reject_recipients_containing=payload.smtp_reject_recipients_containing,
|
||||||
|
)
|
||||||
|
return MockMailboxFailureResponse(config=config)
|
||||||
1111
src/govoplan_mail/backend/mail_profiles.py
Normal file
1111
src/govoplan_mail/backend/mail_profiles.py
Normal file
File diff suppressed because it is too large
Load Diff
83
src/govoplan_mail/backend/manifest.py
Normal file
83
src/govoplan_mail/backend/manifest.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, PermissionDefinition, RoleTemplate
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_mail.backend.db import models as mail_models # noqa: F401 - populate Mail ORM metadata
|
||||||
|
|
||||||
|
|
||||||
|
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||||
|
module_id, resource, action = scope.split(":", 2)
|
||||||
|
return PermissionDefinition(
|
||||||
|
scope=scope,
|
||||||
|
label=label,
|
||||||
|
description=description,
|
||||||
|
category="Mail",
|
||||||
|
level="tenant",
|
||||||
|
module_id=module_id,
|
||||||
|
resource=resource,
|
||||||
|
action=action,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
PERMISSIONS = (
|
||||||
|
_permission("mail:profile:read", "View mail profiles", "Inspect reusable SMTP/IMAP profile metadata."),
|
||||||
|
_permission("mail:profile:use", "Use mail profiles", "Select an approved mail profile for delivery."),
|
||||||
|
_permission("mail:profile:test", "Test mail profiles", "Run SMTP/IMAP connection tests."),
|
||||||
|
_permission("mail:profile:write", "Manage mail profiles", "Create and edit reusable mail profiles."),
|
||||||
|
_permission("mail:secret:manage", "Manage mail secrets", "Create or replace stored SMTP/IMAP credentials."),
|
||||||
|
)
|
||||||
|
|
||||||
|
ROLE_TEMPLATES = (
|
||||||
|
RoleTemplate(
|
||||||
|
slug="mail_profile_admin",
|
||||||
|
name="Mail profile administrator",
|
||||||
|
description="Manage reusable mail profiles and credentials.",
|
||||||
|
permissions=(
|
||||||
|
"mail:profile:read",
|
||||||
|
"mail:profile:use",
|
||||||
|
"mail:profile:test",
|
||||||
|
"mail:profile:write",
|
||||||
|
"mail:secret:manage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
RoleTemplate(
|
||||||
|
slug="mail_profile_user",
|
||||||
|
name="Mail profile user",
|
||||||
|
description="Use and test approved mail profiles without reading secrets.",
|
||||||
|
permissions=("mail:profile:read", "mail:profile:use", "mail:profile:test"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mail_router(context: ModuleContext):
|
||||||
|
from govoplan_mail.backend.runtime import configure_runtime
|
||||||
|
|
||||||
|
configure_runtime(settings=context.settings)
|
||||||
|
from govoplan_mail.backend.router import router
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
manifest = ModuleManifest(
|
||||||
|
id="mail",
|
||||||
|
name="Mail",
|
||||||
|
version="1.0.0",
|
||||||
|
dependencies=("access",),
|
||||||
|
optional_dependencies=(),
|
||||||
|
permissions=PERMISSIONS,
|
||||||
|
route_factory=_mail_router,
|
||||||
|
role_templates=ROLE_TEMPLATES,
|
||||||
|
frontend=FrontendModule(module_id="mail", package_name="@govoplan/mail-webui"),
|
||||||
|
migration_spec=MigrationSpec(
|
||||||
|
module_id="mail",
|
||||||
|
metadata=Base.metadata,
|
||||||
|
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_manifest() -> ModuleManifest:
|
||||||
|
return manifest
|
||||||
|
|
||||||
0
src/govoplan_mail/backend/migrations/__init__.py
Normal file
0
src/govoplan_mail/backend/migrations/__init__.py
Normal file
420
src/govoplan_mail/backend/router.py
Normal file
420
src/govoplan_mail/backend/router.py
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
|
||||||
|
from govoplan_mail.backend.schemas import (
|
||||||
|
MailConnectionTestResponse,
|
||||||
|
MailImapFolderListResponse,
|
||||||
|
MailImapFolderResponse,
|
||||||
|
MailImapTestRequest,
|
||||||
|
MailProfilePolicyResponse,
|
||||||
|
MailProfilePolicyUpdateRequest,
|
||||||
|
MailServerProfileCreateRequest,
|
||||||
|
MailServerProfileListResponse,
|
||||||
|
MailServerProfileResponse,
|
||||||
|
MailServerProfileUpdateRequest,
|
||||||
|
MailSmtpTestRequest,
|
||||||
|
)
|
||||||
|
from govoplan_core.auth.dependencies import ApiPrincipal, get_api_principal, has_scope
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_mail.backend.mail_profiles import (
|
||||||
|
MailProfileError,
|
||||||
|
create_mail_server_profile,
|
||||||
|
effective_mail_profile_policy,
|
||||||
|
get_mail_profile_policy,
|
||||||
|
get_mail_server_profile,
|
||||||
|
imap_config_from_profile,
|
||||||
|
list_mail_server_profiles,
|
||||||
|
profile_response_payload,
|
||||||
|
set_mail_profile_policy,
|
||||||
|
smtp_config_from_profile,
|
||||||
|
update_mail_server_profile,
|
||||||
|
)
|
||||||
|
from govoplan_mail.backend.sending.imap import list_imap_folders, test_imap_login
|
||||||
|
from govoplan_mail.backend.sending.smtp import test_smtp_login
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/mail", tags=["mail"])
|
||||||
|
|
||||||
|
|
||||||
|
def _require_scope(principal: ApiPrincipal, scope: str) -> None:
|
||||||
|
if not has_scope(principal, scope):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None:
|
||||||
|
if not any(has_scope(principal, scope) for scope in scopes):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Requires one of: " + ", ".join(scopes))
|
||||||
|
|
||||||
|
|
||||||
|
def _require_profile_write_scope(principal: ApiPrincipal, scope_type: str) -> None:
|
||||||
|
if scope_type == "system":
|
||||||
|
_require_scope(principal, "system:settings:write")
|
||||||
|
else:
|
||||||
|
_require_scope(principal, "mail:profile:write")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_profile_credentials_scope(principal: ApiPrincipal, scope_type: str) -> None:
|
||||||
|
if scope_type == "system":
|
||||||
|
_require_scope(principal, "system:settings:write")
|
||||||
|
else:
|
||||||
|
_require_scope(principal, "mail:secret:manage")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_policy_read_scope(principal: ApiPrincipal, scope_type: str) -> None:
|
||||||
|
if scope_type == "system":
|
||||||
|
_require_scope(principal, "system:settings:read")
|
||||||
|
elif scope_type == "tenant":
|
||||||
|
_require_any_scope(principal, "admin:policies:read", "mail:profile:read")
|
||||||
|
else:
|
||||||
|
_require_any_scope(principal, "admin:policies:read", "mail:profile:read", "campaigns:campaign:read")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_policy_write_scope(principal: ApiPrincipal, scope_type: str) -> None:
|
||||||
|
if scope_type == "system":
|
||||||
|
_require_scope(principal, "system:settings:write")
|
||||||
|
elif scope_type == "tenant":
|
||||||
|
_require_scope(principal, "admin:policies:write")
|
||||||
|
else:
|
||||||
|
_require_any_scope(principal, "admin:policies:write", "mail:profile:write")
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_response(profile) -> MailServerProfileResponse:
|
||||||
|
return MailServerProfileResponse.model_validate(profile_response_payload(profile))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/profiles", response_model=MailServerProfileListResponse)
|
||||||
|
def list_profiles(
|
||||||
|
include_inactive: bool = False,
|
||||||
|
campaign_id: str | None = Query(default=None),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_require_any_scope(principal, "mail:profile:read", "system:settings:read")
|
||||||
|
try:
|
||||||
|
profiles = list_mail_server_profiles(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
include_inactive=include_inactive,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
)
|
||||||
|
return MailServerProfileListResponse(profiles=[_profile_response(profile) for profile in profiles])
|
||||||
|
except MailProfileError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/profiles", response_model=MailServerProfileResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_profile(
|
||||||
|
payload: MailServerProfileCreateRequest,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_require_profile_write_scope(principal, payload.scope_type)
|
||||||
|
if payload.smtp.password or (payload.imap and payload.imap.password):
|
||||||
|
_require_profile_credentials_scope(principal, payload.scope_type)
|
||||||
|
try:
|
||||||
|
profile = create_mail_server_profile(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
name=payload.name,
|
||||||
|
slug=payload.slug,
|
||||||
|
description=payload.description,
|
||||||
|
smtp=payload.smtp,
|
||||||
|
imap=payload.imap,
|
||||||
|
is_active=payload.is_active,
|
||||||
|
scope_type=payload.scope_type,
|
||||||
|
scope_id=payload.scope_id,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(profile)
|
||||||
|
return _profile_response(profile)
|
||||||
|
except MailProfileError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/profiles/{profile_id}", response_model=MailServerProfileResponse)
|
||||||
|
def get_profile(
|
||||||
|
profile_id: str,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_require_any_scope(principal, "mail:profile:read", "system:settings:read")
|
||||||
|
try:
|
||||||
|
return _profile_response(get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id))
|
||||||
|
except MailProfileError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/profiles/{profile_id}", response_model=MailServerProfileResponse)
|
||||||
|
def update_profile(
|
||||||
|
profile_id: str,
|
||||||
|
payload: MailServerProfileUpdateRequest,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
||||||
|
_require_profile_write_scope(principal, profile.scope_type or "tenant")
|
||||||
|
if (payload.smtp and "password" in payload.smtp.model_fields_set) or (payload.imap and "password" in payload.imap.model_fields_set) or payload.clear_imap:
|
||||||
|
_require_profile_credentials_scope(principal, profile.scope_type or "tenant")
|
||||||
|
update_mail_server_profile(
|
||||||
|
session,
|
||||||
|
profile,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
name=payload.name,
|
||||||
|
slug=payload.slug,
|
||||||
|
description=payload.description if "description" in payload.model_fields_set else None,
|
||||||
|
is_active=payload.is_active,
|
||||||
|
smtp=payload.smtp,
|
||||||
|
imap=payload.imap,
|
||||||
|
clear_imap=payload.clear_imap,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(profile)
|
||||||
|
return _profile_response(profile)
|
||||||
|
except MailProfileError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/profiles/{profile_id}", response_model=MailServerProfileResponse)
|
||||||
|
def deactivate_profile(
|
||||||
|
profile_id: str,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
||||||
|
_require_profile_write_scope(principal, profile.scope_type or "tenant")
|
||||||
|
profile.is_active = False
|
||||||
|
session.add(profile)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(profile)
|
||||||
|
return _profile_response(profile)
|
||||||
|
except MailProfileError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/policies/{scope_type}", response_model=MailProfilePolicyResponse)
|
||||||
|
def read_mail_profile_policy(
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None = Query(default=None),
|
||||||
|
campaign_id: str | None = Query(default=None),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
scope_type = scope_type.strip().casefold()
|
||||||
|
_require_policy_read_scope(principal, scope_type)
|
||||||
|
try:
|
||||||
|
policy = get_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id)
|
||||||
|
effective = None
|
||||||
|
if campaign_id:
|
||||||
|
effective = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=campaign_id).as_dict()
|
||||||
|
elif scope_type == "campaign" and scope_id:
|
||||||
|
effective = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=scope_id).as_dict()
|
||||||
|
return MailProfilePolicyResponse(scope_type=scope_type, scope_id=scope_id, policy=policy, effective_policy=effective)
|
||||||
|
except MailProfileError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/policies/{scope_type}", response_model=MailProfilePolicyResponse)
|
||||||
|
def write_mail_profile_policy(
|
||||||
|
scope_type: str,
|
||||||
|
payload: MailProfilePolicyUpdateRequest,
|
||||||
|
scope_id: str | None = Query(default=None),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
scope_type = scope_type.strip().casefold()
|
||||||
|
_require_policy_write_scope(principal, scope_type)
|
||||||
|
try:
|
||||||
|
policy = set_mail_profile_policy(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
scope_type=scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
|
policy=payload.policy.model_dump(mode="json"),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
effective = None
|
||||||
|
if scope_type == "campaign" and scope_id:
|
||||||
|
effective = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=scope_id).as_dict()
|
||||||
|
return MailProfilePolicyResponse(scope_type=scope_type, scope_id=scope_id, policy=policy, effective_policy=effective)
|
||||||
|
except MailProfileError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/profiles/{profile_id}/test-smtp", response_model=MailConnectionTestResponse)
|
||||||
|
def test_profile_smtp(
|
||||||
|
profile_id: str,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_require_scope(principal, "mail:profile:test")
|
||||||
|
_require_scope(principal, "mail:profile:use")
|
||||||
|
try:
|
||||||
|
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id, require_active=True)
|
||||||
|
smtp = smtp_config_from_profile(profile)
|
||||||
|
result = test_smtp_login(smtp_config=smtp)
|
||||||
|
return MailConnectionTestResponse(ok=True, protocol="smtp", host=result.host, port=result.port, security=result.security, message="SMTP connection successful.", details={"authenticated": result.authenticated})
|
||||||
|
except MailProfileError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
return MailConnectionTestResponse(ok=False, protocol="smtp", message=_safe_error_message(exc), details={"error_type": exc.__class__.__name__})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/profiles/{profile_id}/test-imap", response_model=MailConnectionTestResponse)
|
||||||
|
def test_profile_imap(
|
||||||
|
profile_id: str,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_require_scope(principal, "mail:profile:test")
|
||||||
|
_require_scope(principal, "mail:profile:use")
|
||||||
|
try:
|
||||||
|
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id, require_active=True)
|
||||||
|
imap = imap_config_from_profile(profile)
|
||||||
|
if imap is None:
|
||||||
|
raise MailProfileError("Mail-server profile has no IMAP configuration")
|
||||||
|
result = test_imap_login(imap_config=imap)
|
||||||
|
return MailConnectionTestResponse(ok=True, protocol="imap", host=result.host, port=result.port, security=result.security, message="IMAP connection successful.", details={"authenticated": result.authenticated})
|
||||||
|
except MailProfileError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
return MailConnectionTestResponse(ok=False, protocol="imap", message=_safe_error_message(exc), details={"error_type": exc.__class__.__name__})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/profiles/{profile_id}/list-imap-folders", response_model=MailImapFolderListResponse)
|
||||||
|
def list_profile_imap_folders(
|
||||||
|
profile_id: str,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_require_scope(principal, "mail:profile:test")
|
||||||
|
_require_scope(principal, "mail:profile:use")
|
||||||
|
try:
|
||||||
|
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id, require_active=True)
|
||||||
|
imap = imap_config_from_profile(profile)
|
||||||
|
if imap is None:
|
||||||
|
raise MailProfileError("Mail-server profile has no IMAP configuration")
|
||||||
|
result = list_imap_folders(imap_config=imap)
|
||||||
|
folders = [MailImapFolderResponse(name=item.name, flags=item.flags) for item in result.folders]
|
||||||
|
return MailImapFolderListResponse(ok=True, host=result.host, port=result.port, security=result.security, message=f"Found {len(folders)} IMAP folder(s).", folders=folders, detected_sent_folder=result.detected_sent_folder)
|
||||||
|
except MailProfileError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
return MailImapFolderListResponse(ok=False, message=_safe_error_message(exc), folders=[], detected_sent_folder=None, details={"error_type": exc.__class__.__name__})
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_error_message(exc: Exception) -> str:
|
||||||
|
text = str(exc).strip()
|
||||||
|
return text or exc.__class__.__name__
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test-smtp", response_model=MailConnectionTestResponse)
|
||||||
|
def test_smtp_settings(
|
||||||
|
payload: MailSmtpTestRequest,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
):
|
||||||
|
"""Test SMTP connectivity/login without sending any message."""
|
||||||
|
|
||||||
|
if not has_scope(principal, "mail:profile:test"):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: mail_servers:test")
|
||||||
|
if not has_scope(principal, "mail:secret:manage"):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Testing raw credentials requires mail_servers:manage_credentials")
|
||||||
|
try:
|
||||||
|
result = test_smtp_login(smtp_config=payload)
|
||||||
|
return MailConnectionTestResponse(
|
||||||
|
ok=True,
|
||||||
|
protocol="smtp",
|
||||||
|
host=result.host,
|
||||||
|
port=result.port,
|
||||||
|
security=result.security,
|
||||||
|
message="SMTP connection successful.",
|
||||||
|
details={"authenticated": result.authenticated},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return MailConnectionTestResponse(
|
||||||
|
ok=False,
|
||||||
|
protocol="smtp",
|
||||||
|
host=payload.host,
|
||||||
|
port=payload.port,
|
||||||
|
security=payload.security.value,
|
||||||
|
message=_safe_error_message(exc),
|
||||||
|
details={"error_type": exc.__class__.__name__},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test-imap", response_model=MailConnectionTestResponse)
|
||||||
|
def test_imap_settings(
|
||||||
|
payload: MailImapTestRequest,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
):
|
||||||
|
"""Test IMAP connectivity/login without selecting or appending messages."""
|
||||||
|
|
||||||
|
if not has_scope(principal, "mail:profile:test"):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: mail_servers:test")
|
||||||
|
if not has_scope(principal, "mail:secret:manage"):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Testing raw credentials requires mail_servers:manage_credentials")
|
||||||
|
try:
|
||||||
|
result = test_imap_login(imap_config=payload)
|
||||||
|
return MailConnectionTestResponse(
|
||||||
|
ok=True,
|
||||||
|
protocol="imap",
|
||||||
|
host=result.host,
|
||||||
|
port=result.port,
|
||||||
|
security=result.security,
|
||||||
|
message="IMAP connection successful.",
|
||||||
|
details={"authenticated": result.authenticated},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return MailConnectionTestResponse(
|
||||||
|
ok=False,
|
||||||
|
protocol="imap",
|
||||||
|
host=payload.host,
|
||||||
|
port=payload.port,
|
||||||
|
security=payload.security.value,
|
||||||
|
message=_safe_error_message(exc),
|
||||||
|
details={"error_type": exc.__class__.__name__},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/list-imap-folders", response_model=MailImapFolderListResponse)
|
||||||
|
def list_imap_folder_settings(
|
||||||
|
payload: MailImapTestRequest,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
):
|
||||||
|
"""List visible IMAP folders and return the best Sent-folder guess."""
|
||||||
|
|
||||||
|
if not has_scope(principal, "mail:profile:test"):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: mail_servers:test")
|
||||||
|
if not has_scope(principal, "mail:secret:manage"):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Testing raw credentials requires mail_servers:manage_credentials")
|
||||||
|
try:
|
||||||
|
result = list_imap_folders(imap_config=payload)
|
||||||
|
folders = [MailImapFolderResponse(name=item.name, flags=item.flags) for item in result.folders]
|
||||||
|
return MailImapFolderListResponse(
|
||||||
|
ok=True,
|
||||||
|
host=result.host,
|
||||||
|
port=result.port,
|
||||||
|
security=result.security,
|
||||||
|
message=f"Found {len(folders)} IMAP folder(s).",
|
||||||
|
folders=folders,
|
||||||
|
detected_sent_folder=result.detected_sent_folder,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return MailImapFolderListResponse(
|
||||||
|
ok=False,
|
||||||
|
host=payload.host,
|
||||||
|
port=payload.port,
|
||||||
|
security=payload.security.value,
|
||||||
|
message=_safe_error_message(exc),
|
||||||
|
folders=[],
|
||||||
|
detected_sent_folder=None,
|
||||||
|
details={"error_type": exc.__class__.__name__},
|
||||||
|
)
|
||||||
29
src/govoplan_mail/backend/runtime.py
Normal file
29
src/govoplan_mail/backend/runtime.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_runtime_settings: object | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def configure_runtime(*, settings: object | None = None) -> None:
|
||||||
|
global _runtime_settings
|
||||||
|
if settings is not None:
|
||||||
|
_runtime_settings = settings
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings() -> object:
|
||||||
|
if _runtime_settings is not None:
|
||||||
|
return _runtime_settings
|
||||||
|
try:
|
||||||
|
from govoplan_core.settings import settings as legacy_settings
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
raise RuntimeError("GovOPlaN Mail runtime settings are not configured") from exc
|
||||||
|
return legacy_settings
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsProxy:
|
||||||
|
def __getattr__(self, name: str) -> Any:
|
||||||
|
return getattr(get_settings(), name)
|
||||||
|
|
||||||
|
|
||||||
|
settings = SettingsProxy()
|
||||||
127
src/govoplan_mail/backend/schemas.py
Normal file
127
src/govoplan_mail/backend/schemas.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
|
||||||
|
|
||||||
|
|
||||||
|
class MailSmtpTestRequest(SmtpConfig):
|
||||||
|
"""SMTP settings supplied directly from the WebUI mail settings form."""
|
||||||
|
|
||||||
|
|
||||||
|
class MailImapTestRequest(ImapConfig):
|
||||||
|
"""IMAP settings supplied directly from the WebUI mail settings form."""
|
||||||
|
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
MailProfileScope = Literal["system", "tenant", "user", "group", "campaign"]
|
||||||
|
|
||||||
|
|
||||||
|
class MailCredentialPolicyPayload(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
inherit: bool | None = None
|
||||||
|
allow_override: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MailProfilePolicyPayload(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
allowed_profile_ids: list[str] = Field(default_factory=list)
|
||||||
|
allow_user_profiles: bool | None = None
|
||||||
|
allow_group_profiles: bool | None = None
|
||||||
|
allow_campaign_profiles: bool | None = None
|
||||||
|
smtp_credentials: MailCredentialPolicyPayload = Field(default_factory=MailCredentialPolicyPayload)
|
||||||
|
imap_credentials: MailCredentialPolicyPayload = Field(default_factory=MailCredentialPolicyPayload)
|
||||||
|
whitelist: dict[str, list[str]] = Field(default_factory=dict)
|
||||||
|
blacklist: dict[str, list[str]] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class MailProfilePolicyUpdateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
policy: MailProfilePolicyPayload = Field(default_factory=MailProfilePolicyPayload)
|
||||||
|
|
||||||
|
|
||||||
|
class MailProfilePolicyResponse(BaseModel):
|
||||||
|
scope_type: MailProfileScope
|
||||||
|
scope_id: str | None = None
|
||||||
|
policy: dict[str, Any]
|
||||||
|
effective_policy: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MailServerProfileCreateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
slug: str | None = Field(default=None, max_length=100)
|
||||||
|
description: str | None = None
|
||||||
|
is_active: bool = True
|
||||||
|
scope_type: MailProfileScope = "tenant"
|
||||||
|
scope_id: str | None = None
|
||||||
|
smtp: SmtpConfig
|
||||||
|
imap: ImapConfig | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MailServerProfileUpdateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
name: str | None = Field(default=None, max_length=255)
|
||||||
|
slug: str | None = Field(default=None, max_length=100)
|
||||||
|
description: str | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
smtp: SmtpConfig | None = None
|
||||||
|
imap: ImapConfig | None = None
|
||||||
|
clear_imap: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class MailServerProfileResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
tenant_id: str | None = None
|
||||||
|
scope_type: MailProfileScope = "tenant"
|
||||||
|
scope_id: str | None = None
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
description: str | None = None
|
||||||
|
is_active: bool
|
||||||
|
smtp: dict[str, Any]
|
||||||
|
imap: dict[str, Any] | None = None
|
||||||
|
smtp_password_configured: bool = False
|
||||||
|
imap_password_configured: bool = False
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class MailServerProfileListResponse(BaseModel):
|
||||||
|
profiles: list[MailServerProfileResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class MailConnectionTestResponse(BaseModel):
|
||||||
|
ok: bool
|
||||||
|
protocol: Literal["smtp", "imap"]
|
||||||
|
host: str | None = None
|
||||||
|
port: int | None = None
|
||||||
|
security: str | None = None
|
||||||
|
message: str
|
||||||
|
details: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class MailImapFolderResponse(BaseModel):
|
||||||
|
name: str
|
||||||
|
flags: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class MailImapFolderListResponse(BaseModel):
|
||||||
|
ok: bool
|
||||||
|
protocol: Literal["imap"] = "imap"
|
||||||
|
host: str | None = None
|
||||||
|
port: int | None = None
|
||||||
|
security: str | None = None
|
||||||
|
message: str
|
||||||
|
folders: list[MailImapFolderResponse] = Field(default_factory=list)
|
||||||
|
detected_sent_folder: str | None = None
|
||||||
|
details: dict[str, Any] = Field(default_factory=dict)
|
||||||
0
src/govoplan_mail/backend/sending/__init__.py
Normal file
0
src/govoplan_mail/backend/sending/__init__.py
Normal file
341
src/govoplan_mail/backend/sending/imap.py
Normal file
341
src/govoplan_mail/backend/sending/imap.py
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import imaplib
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from govoplan_mail.backend.config import ImapConfig, TransportSecurity
|
||||||
|
from govoplan_mail.backend.dev.mock_mailbox import (
|
||||||
|
MOCK_IMAP_FOLDERS,
|
||||||
|
consume_fail_next_imap,
|
||||||
|
is_mock_imap_host,
|
||||||
|
record_imap_append,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ImapConfigurationError(ValueError):
|
||||||
|
"""Raised when IMAP settings are incomplete or inconsistent."""
|
||||||
|
|
||||||
|
|
||||||
|
class ImapAppendError(RuntimeError):
|
||||||
|
"""Raised when APPENDing to Sent fails.
|
||||||
|
|
||||||
|
temporary=True means retrying later may help. temporary=False means the
|
||||||
|
configuration or mailbox choice probably needs user/admin attention.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, temporary: bool | None = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.temporary = temporary
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ImapLoginTestResult:
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
security: str
|
||||||
|
authenticated: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ImapMailboxInfo:
|
||||||
|
name: str
|
||||||
|
flags: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ImapFolderListResult:
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
security: str
|
||||||
|
folders: list[ImapMailboxInfo]
|
||||||
|
detected_sent_folder: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ImapAppendResult:
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
security: str
|
||||||
|
folder: str
|
||||||
|
bytes_appended: int
|
||||||
|
response: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _require_imap_config(config: ImapConfig) -> tuple[str, int]:
|
||||||
|
if not config.enabled:
|
||||||
|
raise ImapConfigurationError("IMAP is disabled")
|
||||||
|
if not config.host:
|
||||||
|
raise ImapConfigurationError("IMAP host is required")
|
||||||
|
if not config.port:
|
||||||
|
raise ImapConfigurationError("IMAP port is required")
|
||||||
|
if bool(config.username) != bool(config.password):
|
||||||
|
raise ImapConfigurationError("IMAP username and password must be provided together, or both omitted")
|
||||||
|
return config.host, config.port
|
||||||
|
|
||||||
|
|
||||||
|
def _open_imap(config: ImapConfig) -> imaplib.IMAP4:
|
||||||
|
host, port = _require_imap_config(config)
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if config.security == TransportSecurity.TLS:
|
||||||
|
client: imaplib.IMAP4 = imaplib.IMAP4_SSL(host=host, port=port, timeout=config.timeout_seconds, ssl_context=context)
|
||||||
|
else:
|
||||||
|
client = imaplib.IMAP4(host=host, port=port, timeout=config.timeout_seconds)
|
||||||
|
if config.security == TransportSecurity.STARTTLS:
|
||||||
|
typ, data = client.starttls(ssl_context=context)
|
||||||
|
if typ != "OK":
|
||||||
|
raise ImapAppendError(f"IMAP STARTTLS failed: {data!r}", temporary=True)
|
||||||
|
|
||||||
|
if config.username and config.password:
|
||||||
|
typ, data = client.login(config.username, config.password)
|
||||||
|
if typ != "OK":
|
||||||
|
raise ImapAppendError(f"IMAP login failed: {data!r}", temporary=False)
|
||||||
|
return client
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
client.logout() # type: ignore[possibly-undefined]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_item(item: bytes | str | None) -> str:
|
||||||
|
if item is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(item, bytes):
|
||||||
|
return item.decode("utf-8", errors="replace")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _unquote_imap_token(value: str) -> str:
|
||||||
|
value = value.strip()
|
||||||
|
if len(value) >= 2 and value[0] == '"' and value[-1] == '"':
|
||||||
|
value = value[1:-1]
|
||||||
|
value = value.replace('\\"', '"').replace('\\\\', '\\')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str]] | None:
|
||||||
|
r"""Best-effort parser for IMAP LIST response lines.
|
||||||
|
|
||||||
|
RFC 3501 LIST responses contain attributes, hierarchy delimiter, then mailbox
|
||||||
|
name. Some servers quote both delimiter and mailbox::
|
||||||
|
|
||||||
|
(\HasNoChildren \Sent) "/" "Sent"
|
||||||
|
|
||||||
|
Others quote only the delimiter and leave the mailbox as an atom::
|
||||||
|
|
||||||
|
(\HasNoChildren \Sent) "/" Sent
|
||||||
|
|
||||||
|
The parser must therefore parse the delimiter token separately instead of
|
||||||
|
blindly taking the last quoted value.
|
||||||
|
"""
|
||||||
|
|
||||||
|
line = _decode_item(list_response_line).strip()
|
||||||
|
match = re.match(
|
||||||
|
r'^\((?P<flags>[^)]*)\)\s+'
|
||||||
|
r'(?P<delimiter>"(?:[^"\\]|\\.)*"|NIL|[^\s]+)\s+'
|
||||||
|
r'(?P<mailbox>.+?)\s*$',
|
||||||
|
line,
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
if match:
|
||||||
|
flags = {part.lower() for part in match.group("flags").split()}
|
||||||
|
mailbox = _unquote_imap_token(match.group("mailbox"))
|
||||||
|
if mailbox:
|
||||||
|
return mailbox, flags
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Fallback for non-standard server lines: prefer the final token.
|
||||||
|
parts = line.split(maxsplit=2)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
flags_text = parts[0].strip("()")
|
||||||
|
flags = {part.lower() for part in flags_text.split()}
|
||||||
|
mailbox = _unquote_imap_token(parts[2])
|
||||||
|
if mailbox:
|
||||||
|
return mailbox, flags
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_sent_folder(parsed: list[tuple[str, set[str]]]) -> str | None:
|
||||||
|
for name, flags in parsed:
|
||||||
|
if "\\sent" in flags or "\\sentmail" in flags:
|
||||||
|
return name
|
||||||
|
|
||||||
|
common_names = [
|
||||||
|
"Sent",
|
||||||
|
"Sent Items",
|
||||||
|
"Sent Messages",
|
||||||
|
"Gesendet",
|
||||||
|
"Gesendete Elemente",
|
||||||
|
"INBOX.Sent",
|
||||||
|
"INBOX/Sent",
|
||||||
|
]
|
||||||
|
names = {name.lower(): name for name, _ in parsed}
|
||||||
|
for candidate in common_names:
|
||||||
|
if candidate.lower() in names:
|
||||||
|
return names[candidate.lower()]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def discover_sent_folder(client: imaplib.IMAP4) -> str | None:
|
||||||
|
typ, data = client.list()
|
||||||
|
if typ != "OK" or not data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
parsed: list[tuple[str, set[str]]] = []
|
||||||
|
for item in data:
|
||||||
|
extracted = _extract_mailbox_name(item)
|
||||||
|
if extracted:
|
||||||
|
parsed.append(extracted)
|
||||||
|
|
||||||
|
return _detect_sent_folder(parsed)
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_sent_folder(*, config: ImapConfig, requested_folder: str | None, client: imaplib.IMAP4) -> str:
|
||||||
|
if requested_folder and requested_folder != "auto":
|
||||||
|
return requested_folder
|
||||||
|
if config.sent_folder and config.sent_folder != "auto":
|
||||||
|
return config.sent_folder
|
||||||
|
discovered = discover_sent_folder(client)
|
||||||
|
if discovered:
|
||||||
|
return discovered
|
||||||
|
raise ImapConfigurationError("Could not discover Sent folder; configure delivery.imap_append_sent.folder or server.imap.sent_folder")
|
||||||
|
|
||||||
|
|
||||||
|
def test_imap_login(*, imap_config: ImapConfig) -> ImapLoginTestResult:
|
||||||
|
"""Open an IMAP connection and authenticate if credentials are configured.
|
||||||
|
|
||||||
|
This is a side-effect-free connection test for the WebUI. It does not select
|
||||||
|
a mailbox and does not append any message.
|
||||||
|
"""
|
||||||
|
|
||||||
|
host, port = _require_imap_config(imap_config)
|
||||||
|
if is_mock_imap_host(imap_config.host):
|
||||||
|
return ImapLoginTestResult(
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
security=imap_config.security.value,
|
||||||
|
authenticated=bool(imap_config.username and imap_config.password),
|
||||||
|
)
|
||||||
|
|
||||||
|
client = _open_imap(imap_config)
|
||||||
|
try:
|
||||||
|
return ImapLoginTestResult(
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
security=imap_config.security.value,
|
||||||
|
authenticated=bool(imap_config.username and imap_config.password),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
client.logout()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
|
||||||
|
"""Return folders visible through IMAP LIST and the best sent-folder guess."""
|
||||||
|
|
||||||
|
host, port = _require_imap_config(imap_config)
|
||||||
|
if is_mock_imap_host(imap_config.host):
|
||||||
|
folders = [ImapMailboxInfo(name=str(item["name"]), flags=list(item.get("flags") or [])) for item in MOCK_IMAP_FOLDERS]
|
||||||
|
return ImapFolderListResult(
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
security=imap_config.security.value,
|
||||||
|
folders=folders,
|
||||||
|
detected_sent_folder="Sent",
|
||||||
|
)
|
||||||
|
|
||||||
|
client = _open_imap(imap_config)
|
||||||
|
try:
|
||||||
|
typ, data = client.list()
|
||||||
|
if typ != "OK":
|
||||||
|
raise ImapAppendError(f"IMAP folder listing failed: {data!r}", temporary=True)
|
||||||
|
|
||||||
|
parsed: list[tuple[str, set[str]]] = []
|
||||||
|
folders: list[ImapMailboxInfo] = []
|
||||||
|
for item in data or []:
|
||||||
|
extracted = _extract_mailbox_name(item)
|
||||||
|
if not extracted:
|
||||||
|
continue
|
||||||
|
name, flags = extracted
|
||||||
|
parsed.append((name, flags))
|
||||||
|
folders.append(ImapMailboxInfo(name=name, flags=sorted(flags)))
|
||||||
|
|
||||||
|
return ImapFolderListResult(
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
security=imap_config.security.value,
|
||||||
|
folders=folders,
|
||||||
|
detected_sent_folder=_detect_sent_folder(parsed),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
client.logout()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def append_message_to_sent(
|
||||||
|
message_bytes: bytes,
|
||||||
|
*,
|
||||||
|
imap_config: ImapConfig,
|
||||||
|
folder: str | None = None,
|
||||||
|
) -> ImapAppendResult:
|
||||||
|
"""Append a sent MIME message to the configured IMAP Sent folder.
|
||||||
|
|
||||||
|
The SMTP send remains authoritative. APPEND is a separate best-effort step
|
||||||
|
and should not be used to decide whether an email was sent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
host, port = _require_imap_config(imap_config)
|
||||||
|
if is_mock_imap_host(imap_config.host):
|
||||||
|
if consume_fail_next_imap():
|
||||||
|
raise ImapAppendError("Mock IMAP configured to fail the next append", temporary=False)
|
||||||
|
target_folder = folder or (imap_config.sent_folder if imap_config.sent_folder and imap_config.sent_folder != "auto" else "Sent")
|
||||||
|
record = record_imap_append(message_bytes, folder=target_folder, imap_host=imap_config.host)
|
||||||
|
return ImapAppendResult(
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
security=imap_config.security.value,
|
||||||
|
folder=target_folder,
|
||||||
|
bytes_appended=len(message_bytes),
|
||||||
|
response=f"mock append stored as {record.id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
client: imaplib.IMAP4 | None = None
|
||||||
|
try:
|
||||||
|
client = _open_imap(imap_config)
|
||||||
|
target_folder = _effective_sent_folder(config=imap_config, requested_folder=folder, client=client)
|
||||||
|
internal_date = imaplib.Time2Internaldate(time.time())
|
||||||
|
typ, data = client.append(target_folder, "\\Seen", internal_date, message_bytes)
|
||||||
|
if typ != "OK":
|
||||||
|
raise ImapAppendError(f"IMAP APPEND failed for folder {target_folder!r}: {data!r}", temporary=False)
|
||||||
|
response = "; ".join(_decode_item(item) for item in (data or [])) or None
|
||||||
|
return ImapAppendResult(
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
security=imap_config.security.value,
|
||||||
|
folder=target_folder,
|
||||||
|
bytes_appended=len(message_bytes),
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
except ImapAppendError:
|
||||||
|
raise
|
||||||
|
except (OSError, socket.timeout, imaplib.IMAP4.abort) as exc:
|
||||||
|
raise ImapAppendError(f"IMAP append failed: {exc}", temporary=True) from exc
|
||||||
|
except imaplib.IMAP4.error as exc:
|
||||||
|
raise ImapAppendError(f"IMAP append failed: {exc}", temporary=False) from exc
|
||||||
|
finally:
|
||||||
|
if client is not None:
|
||||||
|
try:
|
||||||
|
client.logout()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
57
src/govoplan_mail/backend/sending/rate_limit.py
Normal file
57
src/govoplan_mail/backend/sending/rate_limit.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from redis import Redis
|
||||||
|
from redis.exceptions import RedisError
|
||||||
|
|
||||||
|
from govoplan_mail.backend.runtime import settings
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RateLimitDecision:
|
||||||
|
key: str
|
||||||
|
messages_per_minute: int
|
||||||
|
gap_seconds: float
|
||||||
|
waited_seconds: float
|
||||||
|
|
||||||
|
|
||||||
|
def _redis_client() -> Redis:
|
||||||
|
return Redis.from_url(settings.redis_url, decode_responses=True)
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_rate_limit(*, key: str, messages_per_minute: int, enabled: bool = True) -> RateLimitDecision:
|
||||||
|
"""Throttle sends across worker processes using Redis when available.
|
||||||
|
|
||||||
|
The implementation stores the next allowed send timestamp per key. A Redis
|
||||||
|
lock keeps multiple Celery processes from reading/updating the timestamp at
|
||||||
|
the same time. If Redis is unavailable, it falls back to no distributed wait;
|
||||||
|
the per-container Celery concurrency still protects local development.
|
||||||
|
"""
|
||||||
|
|
||||||
|
messages_per_minute = max(1, int(messages_per_minute or 1))
|
||||||
|
gap = 60.0 / messages_per_minute
|
||||||
|
if not enabled:
|
||||||
|
return RateLimitDecision(key=key, messages_per_minute=messages_per_minute, gap_seconds=gap, waited_seconds=0.0)
|
||||||
|
|
||||||
|
redis_key = f"multimailer:ratelimit:{key}:next_allowed"
|
||||||
|
lock_key = f"multimailer:ratelimit:{key}:lock"
|
||||||
|
waited = 0.0
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = _redis_client()
|
||||||
|
with client.lock(lock_key, timeout=30, blocking_timeout=30):
|
||||||
|
now = time.time()
|
||||||
|
raw_next = client.get(redis_key)
|
||||||
|
next_allowed = float(raw_next) if raw_next else now
|
||||||
|
if next_allowed > now:
|
||||||
|
waited = next_allowed - now
|
||||||
|
time.sleep(waited)
|
||||||
|
now = time.time()
|
||||||
|
client.set(redis_key, now + gap, ex=max(60, int(gap * 10)))
|
||||||
|
except (RedisError, TimeoutError, ValueError):
|
||||||
|
# Development fallback: do not fail sending because Redis is absent.
|
||||||
|
waited = 0.0
|
||||||
|
|
||||||
|
return RateLimitDecision(key=key, messages_per_minute=messages_per_minute, gap_seconds=gap, waited_seconds=waited)
|
||||||
322
src/govoplan_mail/backend/sending/smtp.py
Normal file
322
src/govoplan_mail/backend/sending/smtp.py
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import smtplib
|
||||||
|
import ssl
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from email.utils import formataddr
|
||||||
|
|
||||||
|
from govoplan_mail.backend.config import SmtpConfig, TransportSecurity
|
||||||
|
from govoplan_mail.backend.dev.mock_mailbox import (
|
||||||
|
consume_fail_next_smtp,
|
||||||
|
get_failures,
|
||||||
|
is_mock_smtp_host,
|
||||||
|
record_smtp_delivery,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SmtpConfigurationError(ValueError):
|
||||||
|
"""Raised when SMTP settings are incomplete or inconsistent."""
|
||||||
|
|
||||||
|
|
||||||
|
class SmtpSendError(RuntimeError):
|
||||||
|
"""Raised when an SMTP send attempt fails.
|
||||||
|
|
||||||
|
``temporary`` means an explicit response allows a later retry. An
|
||||||
|
``outcome_unknown`` failure happened after SMTP transmission may have
|
||||||
|
started, so automatic retry is intentionally forbidden.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, temporary: bool = False, outcome_unknown: bool = False):
|
||||||
|
super().__init__(message)
|
||||||
|
self.temporary = temporary
|
||||||
|
self.outcome_unknown = outcome_unknown
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SmtpLoginTestResult:
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
security: str
|
||||||
|
authenticated: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SmtpSendResult:
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
security: str
|
||||||
|
envelope_from: str
|
||||||
|
envelope_recipients: list[str]
|
||||||
|
refused_recipients: dict[str, tuple[int, bytes | str]]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def accepted_count(self) -> int:
|
||||||
|
return len(self.envelope_recipients) - len(self.refused_recipients)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_smtp_config(config: SmtpConfig) -> tuple[str, int]:
|
||||||
|
if not config.host:
|
||||||
|
raise SmtpConfigurationError("SMTP host is required")
|
||||||
|
if not config.port:
|
||||||
|
raise SmtpConfigurationError("SMTP port is required")
|
||||||
|
if bool(config.username) != bool(config.password):
|
||||||
|
raise SmtpConfigurationError("SMTP username and password must be provided together, or both omitted")
|
||||||
|
return config.host, config.port
|
||||||
|
|
||||||
|
|
||||||
|
def _open_smtp(config: SmtpConfig) -> smtplib.SMTP:
|
||||||
|
host, port = _require_smtp_config(config)
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if config.security == TransportSecurity.TLS:
|
||||||
|
smtp: smtplib.SMTP = smtplib.SMTP_SSL(host=host, port=port, timeout=config.timeout_seconds, context=context)
|
||||||
|
smtp.ehlo()
|
||||||
|
else:
|
||||||
|
smtp = smtplib.SMTP(host=host, port=port, timeout=config.timeout_seconds)
|
||||||
|
smtp.ehlo()
|
||||||
|
if config.security == TransportSecurity.STARTTLS:
|
||||||
|
smtp.starttls(context=context)
|
||||||
|
smtp.ehlo()
|
||||||
|
|
||||||
|
if config.username and config.password:
|
||||||
|
smtp.login(config.username, config.password)
|
||||||
|
return smtp
|
||||||
|
except Exception:
|
||||||
|
# If construction/login fails after a socket was created, smtplib usually closes
|
||||||
|
# on GC, but explicit cleanup is safer when the variable exists.
|
||||||
|
try:
|
||||||
|
smtp.quit() # type: ignore[possibly-undefined]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_refused(refused: dict[str, tuple[int, bytes]]) -> dict[str, tuple[int, bytes | str]]:
|
||||||
|
normalized: dict[str, tuple[int, bytes | str]] = {}
|
||||||
|
for recipient, (code, response) in refused.items():
|
||||||
|
try:
|
||||||
|
normalized[recipient] = (code, response.decode("utf-8", errors="replace"))
|
||||||
|
except AttributeError:
|
||||||
|
normalized[recipient] = (code, response)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def test_smtp_login(*, smtp_config: SmtpConfig) -> SmtpLoginTestResult:
|
||||||
|
"""Open an SMTP connection and authenticate if credentials are configured.
|
||||||
|
|
||||||
|
This is intentionally side-effect free: it does not send a message and it
|
||||||
|
never receives envelope or recipient data. It is used by the WebUI to check
|
||||||
|
whether the configured transport can be reached before a campaign is built
|
||||||
|
or queued.
|
||||||
|
"""
|
||||||
|
|
||||||
|
host, port = _require_smtp_config(smtp_config)
|
||||||
|
if is_mock_smtp_host(smtp_config.host):
|
||||||
|
host, port = _require_smtp_config(smtp_config)
|
||||||
|
return SmtpLoginTestResult(
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
security=smtp_config.security.value,
|
||||||
|
authenticated=bool(smtp_config.username and smtp_config.password),
|
||||||
|
)
|
||||||
|
|
||||||
|
smtp = _open_smtp(smtp_config)
|
||||||
|
try:
|
||||||
|
return SmtpLoginTestResult(
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
security=smtp_config.security.value,
|
||||||
|
authenticated=bool(smtp_config.username and smtp_config.password),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
smtp.quit()
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
smtp.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_test_message(
|
||||||
|
message: EmailMessage,
|
||||||
|
*,
|
||||||
|
test_recipient: str,
|
||||||
|
test_recipient_name: str | None = None,
|
||||||
|
) -> EmailMessage:
|
||||||
|
"""Return a safe copy of a generated campaign message for test delivery.
|
||||||
|
|
||||||
|
The original recipient headers are removed so a test send cannot accidentally
|
||||||
|
leak the real To/Cc list or deliver to the real recipients. The envelope
|
||||||
|
recipient must also be supplied separately to send_email_message().
|
||||||
|
"""
|
||||||
|
|
||||||
|
test_message = copy.deepcopy(message)
|
||||||
|
|
||||||
|
for header in ["To", "Cc", "Bcc"]:
|
||||||
|
if header in test_message:
|
||||||
|
del test_message[header]
|
||||||
|
|
||||||
|
# Replace potential previous marker headers if the user test-sends an EML twice.
|
||||||
|
for header in ["X-MultiMailer-Test-Send"]:
|
||||||
|
if header in test_message:
|
||||||
|
del test_message[header]
|
||||||
|
|
||||||
|
test_message["To"] = formataddr((test_recipient_name or test_recipient, test_recipient))
|
||||||
|
test_message["X-MultiMailer-Test-Send"] = "true"
|
||||||
|
return test_message
|
||||||
|
|
||||||
|
|
||||||
|
def _send_smtp_payload(
|
||||||
|
message: EmailMessage | bytes,
|
||||||
|
*,
|
||||||
|
smtp_config: SmtpConfig,
|
||||||
|
envelope_from: str,
|
||||||
|
envelope_recipients: list[str],
|
||||||
|
) -> SmtpSendResult:
|
||||||
|
host, port = _require_smtp_config(smtp_config)
|
||||||
|
if not envelope_from:
|
||||||
|
raise SmtpConfigurationError("SMTP envelope sender is required")
|
||||||
|
if not envelope_recipients:
|
||||||
|
raise SmtpConfigurationError("at least one SMTP envelope recipient is required")
|
||||||
|
|
||||||
|
if is_mock_smtp_host(smtp_config.host):
|
||||||
|
if consume_fail_next_smtp():
|
||||||
|
raise SmtpSendError("Mock SMTP configured to fail the next send")
|
||||||
|
failures = get_failures()
|
||||||
|
reject_text = str(failures.get("smtp_reject_recipients_containing") or "").strip().lower()
|
||||||
|
refused: dict[str, tuple[int, bytes]] = {}
|
||||||
|
accepted = list(envelope_recipients)
|
||||||
|
if reject_text:
|
||||||
|
refused = {
|
||||||
|
recipient: (550, b"mock recipient rejected")
|
||||||
|
for recipient in envelope_recipients
|
||||||
|
if reject_text in recipient.lower()
|
||||||
|
}
|
||||||
|
accepted = [recipient for recipient in envelope_recipients if recipient not in refused]
|
||||||
|
if not accepted:
|
||||||
|
raise SmtpSendError(f"all mock SMTP recipients were refused: {_decode_refused(refused)}")
|
||||||
|
record_smtp_delivery(
|
||||||
|
message,
|
||||||
|
envelope_from=envelope_from,
|
||||||
|
envelope_recipients=accepted,
|
||||||
|
smtp_host=smtp_config.host,
|
||||||
|
)
|
||||||
|
return SmtpSendResult(
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
security=smtp_config.security.value,
|
||||||
|
envelope_from=envelope_from,
|
||||||
|
envelope_recipients=list(envelope_recipients),
|
||||||
|
refused_recipients=_decode_refused(refused),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
smtp = _open_smtp(smtp_config)
|
||||||
|
except smtplib.SMTPAuthenticationError as exc:
|
||||||
|
raise SmtpSendError(
|
||||||
|
f"SMTP authentication failed: {exc.smtp_code} {exc.smtp_error!r}",
|
||||||
|
temporary=False,
|
||||||
|
) from exc
|
||||||
|
except smtplib.SMTPResponseException as exc:
|
||||||
|
raise SmtpSendError(
|
||||||
|
f"SMTP connection error: {exc.smtp_code} {exc.smtp_error!r}",
|
||||||
|
temporary=400 <= int(exc.smtp_code) < 500,
|
||||||
|
) from exc
|
||||||
|
except (OSError, smtplib.SMTPException) as exc:
|
||||||
|
# No message transmission has begun yet; a later explicit retry is safe.
|
||||||
|
raise SmtpSendError(f"SMTP connection failed: {exc}", temporary=True) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
if isinstance(message, bytes):
|
||||||
|
refused = smtp.sendmail(envelope_from, envelope_recipients, message)
|
||||||
|
else:
|
||||||
|
refused = smtp.send_message(
|
||||||
|
message,
|
||||||
|
from_addr=envelope_from,
|
||||||
|
to_addrs=envelope_recipients,
|
||||||
|
)
|
||||||
|
except smtplib.SMTPRecipientsRefused as exc:
|
||||||
|
raise SmtpSendError(
|
||||||
|
f"all SMTP recipients were refused: {_decode_refused(exc.recipients)}",
|
||||||
|
temporary=False,
|
||||||
|
) from exc
|
||||||
|
except smtplib.SMTPSenderRefused as exc:
|
||||||
|
raise SmtpSendError(
|
||||||
|
f"SMTP sender was refused: {exc.smtp_code} {exc.smtp_error!r}",
|
||||||
|
temporary=400 <= int(exc.smtp_code) < 500,
|
||||||
|
) from exc
|
||||||
|
except smtplib.SMTPResponseException as exc:
|
||||||
|
# An explicit SMTP response means the server rejected the transaction;
|
||||||
|
# retryability follows the response class.
|
||||||
|
raise SmtpSendError(
|
||||||
|
f"SMTP error: {exc.smtp_code} {exc.smtp_error!r}",
|
||||||
|
temporary=400 <= int(exc.smtp_code) < 500,
|
||||||
|
) from exc
|
||||||
|
except (OSError, smtplib.SMTPServerDisconnected, smtplib.SMTPException) as exc:
|
||||||
|
# A connection loss after DATA began can happen after the server accepted
|
||||||
|
# the message but before the client received the final response.
|
||||||
|
raise SmtpSendError(
|
||||||
|
f"SMTP outcome is unknown after transmission started: {exc}",
|
||||||
|
outcome_unknown=True,
|
||||||
|
) from exc
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
smtp.quit()
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
smtp.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return SmtpSendResult(
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
security=smtp_config.security.value,
|
||||||
|
envelope_from=envelope_from,
|
||||||
|
envelope_recipients=list(envelope_recipients),
|
||||||
|
refused_recipients=_decode_refused(refused),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_email_bytes(
|
||||||
|
message_bytes: bytes,
|
||||||
|
*,
|
||||||
|
smtp_config: SmtpConfig,
|
||||||
|
envelope_from: str,
|
||||||
|
envelope_recipients: list[str],
|
||||||
|
) -> SmtpSendResult:
|
||||||
|
"""Send exact RFC 5322 bytes through SMTP without reserializing the message."""
|
||||||
|
|
||||||
|
return _send_smtp_payload(
|
||||||
|
message_bytes,
|
||||||
|
smtp_config=smtp_config,
|
||||||
|
envelope_from=envelope_from,
|
||||||
|
envelope_recipients=envelope_recipients,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_email_message(
|
||||||
|
message: EmailMessage,
|
||||||
|
*,
|
||||||
|
smtp_config: SmtpConfig,
|
||||||
|
envelope_from: str,
|
||||||
|
envelope_recipients: list[str],
|
||||||
|
) -> SmtpSendResult:
|
||||||
|
"""Send an EmailMessage through SMTP.
|
||||||
|
|
||||||
|
This low-level function deliberately receives explicit envelope sender and
|
||||||
|
recipients. Headers and SMTP envelope are related but not identical; Bcc and
|
||||||
|
future bounce-address handling depend on keeping them separate. Campaign
|
||||||
|
delivery uses send_email_bytes() so the generated EML is not reserialized.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return _send_smtp_payload(
|
||||||
|
message,
|
||||||
|
smtp_config=smtp_config,
|
||||||
|
envelope_from=envelope_from,
|
||||||
|
envelope_recipients=envelope_recipients,
|
||||||
|
)
|
||||||
0
src/govoplan_mail/py.typed
Normal file
0
src/govoplan_mail/py.typed
Normal file
23
webui/package.json
Normal file
23
webui/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/mail-webui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"module": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"import": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"./styles/mail-profiles.css": "./src/styles/mail-profiles.css"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.0",
|
||||||
|
"lucide-react": "^0.555.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
webui/src/api/client.ts
Normal file
1
webui/src/api/client.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { apiFetch, apiUrl, authHeaders, csrfToken, apiDownload } from "@govoplan/core-webui";
|
||||||
265
webui/src/api/mail.ts
Normal file
265
webui/src/api/mail.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import type { ApiSettings } from "../types";
|
||||||
|
import { apiFetch } from "./client";
|
||||||
|
|
||||||
|
export type MailSecurity = "plain" | "tls" | "starttls";
|
||||||
|
export type MailProfileScope = "system" | "tenant" | "user" | "group" | "campaign";
|
||||||
|
|
||||||
|
export type MailSmtpTestPayload = {
|
||||||
|
host?: string | null;
|
||||||
|
port?: number | null;
|
||||||
|
username?: string | null;
|
||||||
|
password?: string | null;
|
||||||
|
security?: MailSecurity;
|
||||||
|
timeout_seconds?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailImapTestPayload = MailSmtpTestPayload & {
|
||||||
|
enabled?: boolean;
|
||||||
|
sent_folder?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailConnectionTestResponse = {
|
||||||
|
ok: boolean;
|
||||||
|
protocol: "smtp" | "imap";
|
||||||
|
host?: string | null;
|
||||||
|
port?: number | null;
|
||||||
|
security?: MailSecurity | string | null;
|
||||||
|
message: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailImapFolderResponse = {
|
||||||
|
name: string;
|
||||||
|
flags?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailImapFolderListResponse = {
|
||||||
|
ok: boolean;
|
||||||
|
protocol: "imap";
|
||||||
|
host?: string | null;
|
||||||
|
port?: number | null;
|
||||||
|
security?: MailSecurity | string | null;
|
||||||
|
message: string;
|
||||||
|
folders: MailImapFolderResponse[];
|
||||||
|
detected_sent_folder?: string | null;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type MailServerProfile = {
|
||||||
|
id: string;
|
||||||
|
tenant_id?: string | null;
|
||||||
|
scope_type: MailProfileScope;
|
||||||
|
scope_id?: string | null;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description?: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
smtp: MailSmtpTestPayload;
|
||||||
|
imap?: MailImapTestPayload | null;
|
||||||
|
smtp_password_configured: boolean;
|
||||||
|
imap_password_configured: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailServerProfileListResponse = { profiles: MailServerProfile[] };
|
||||||
|
|
||||||
|
export const mailProfilePatternKeys = [
|
||||||
|
"smtp_hosts",
|
||||||
|
"imap_hosts",
|
||||||
|
"envelope_senders",
|
||||||
|
"from_headers",
|
||||||
|
"recipient_domains"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type MailProfilePatternKey = typeof mailProfilePatternKeys[number];
|
||||||
|
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
|
||||||
|
|
||||||
|
export type MailCredentialPolicy = {
|
||||||
|
inherit?: boolean | null;
|
||||||
|
allow_override?: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailProfilePolicy = {
|
||||||
|
allowed_profile_ids?: string[] | null;
|
||||||
|
allow_user_profiles?: boolean | null;
|
||||||
|
allow_group_profiles?: boolean | null;
|
||||||
|
allow_campaign_profiles?: boolean | null;
|
||||||
|
smtp_credentials?: MailCredentialPolicy | null;
|
||||||
|
imap_credentials?: MailCredentialPolicy | null;
|
||||||
|
whitelist?: MailProfilePatternRules | null;
|
||||||
|
blacklist?: MailProfilePatternRules | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailProfilePolicyResponse = {
|
||||||
|
scope_type: MailProfileScope;
|
||||||
|
scope_id?: string | null;
|
||||||
|
policy: MailProfilePolicy;
|
||||||
|
effective_policy?: MailProfilePolicy | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailServerProfilePayload = {
|
||||||
|
name: string;
|
||||||
|
slug?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
is_active?: boolean;
|
||||||
|
scope_type?: MailProfileScope;
|
||||||
|
scope_id?: string | null;
|
||||||
|
smtp: MailSmtpTestPayload;
|
||||||
|
imap?: MailImapTestPayload | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (includeInactive) params.set("include_inactive", "true");
|
||||||
|
if (campaignId) params.set("campaign_id", campaignId);
|
||||||
|
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||||
|
const response = await apiFetch<MailServerProfileListResponse>(settings, `/api/v1/mail/profiles${suffix}`);
|
||||||
|
return response.profiles ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMailServerProfile(settings: ApiSettings, payload: MailServerProfilePayload): Promise<MailServerProfile> {
|
||||||
|
return apiFetch<MailServerProfile>(settings, "/api/v1/mail/profiles", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MailServerProfileUpdatePayload = Partial<MailServerProfilePayload> & { clear_imap?: boolean };
|
||||||
|
|
||||||
|
export async function updateMailServerProfile(settings: ApiSettings, profileId: string, payload: MailServerProfileUpdatePayload): Promise<MailServerProfile> {
|
||||||
|
return apiFetch<MailServerProfile>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deactivateMailServerProfile(settings: ApiSettings, profileId: string): Promise<MailServerProfile> {
|
||||||
|
return apiFetch<MailServerProfile>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMailProfilePolicy(
|
||||||
|
settings: ApiSettings,
|
||||||
|
scopeType: MailProfileScope,
|
||||||
|
scopeId?: string | null,
|
||||||
|
campaignId?: string | null
|
||||||
|
): Promise<MailProfilePolicyResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (scopeId) params.set("scope_id", scopeId);
|
||||||
|
if (campaignId) params.set("campaign_id", campaignId);
|
||||||
|
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||||
|
return apiFetch<MailProfilePolicyResponse>(settings, `/api/v1/mail/policies/${encodeURIComponent(scopeType)}${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMailProfilePolicy(
|
||||||
|
settings: ApiSettings,
|
||||||
|
scopeType: MailProfileScope,
|
||||||
|
policy: MailProfilePolicy,
|
||||||
|
scopeId?: string | null
|
||||||
|
): Promise<MailProfilePolicyResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (scopeId) params.set("scope_id", scopeId);
|
||||||
|
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||||
|
return apiFetch<MailProfilePolicyResponse>(settings, `/api/v1/mail/policies/${encodeURIComponent(scopeType)}${suffix}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ policy })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
|
||||||
|
return apiFetch<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-smtp`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testMailProfileImap(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
|
||||||
|
return apiFetch<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-imap`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMailProfileImapFolders(settings: ApiSettings, profileId: string): Promise<MailImapFolderListResponse> {
|
||||||
|
return apiFetch<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testSmtpSettings(
|
||||||
|
settings: ApiSettings,
|
||||||
|
payload: MailSmtpTestPayload
|
||||||
|
): Promise<MailConnectionTestResponse> {
|
||||||
|
return apiFetch<MailConnectionTestResponse>(settings, "/api/v1/mail/test-smtp", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testImapSettings(
|
||||||
|
settings: ApiSettings,
|
||||||
|
payload: MailImapTestPayload
|
||||||
|
): Promise<MailConnectionTestResponse> {
|
||||||
|
return apiFetch<MailConnectionTestResponse>(settings, "/api/v1/mail/test-imap", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listImapFolders(
|
||||||
|
settings: ApiSettings,
|
||||||
|
payload: MailImapTestPayload
|
||||||
|
): Promise<MailImapFolderListResponse> {
|
||||||
|
return apiFetch<MailImapFolderListResponse>(settings, "/api/v1/mail/list-imap-folders", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MockMailboxMessage = {
|
||||||
|
id: string;
|
||||||
|
kind: "smtp" | "imap_append" | string;
|
||||||
|
created_at: string;
|
||||||
|
envelope_from?: string | null;
|
||||||
|
envelope_recipients?: string[];
|
||||||
|
subject?: string | null;
|
||||||
|
from_header?: string | null;
|
||||||
|
to_header?: string | null;
|
||||||
|
cc_header?: string | null;
|
||||||
|
bcc_header?: string | null;
|
||||||
|
message_id?: string | null;
|
||||||
|
size_bytes?: number;
|
||||||
|
body_preview?: string | null;
|
||||||
|
attachment_count?: number;
|
||||||
|
folder?: string | null;
|
||||||
|
raw_eml?: string | null;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
attachments?: Array<{ filename?: string | null; content_type?: string | null; size_bytes?: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MockMailboxListResponse = {
|
||||||
|
messages: MockMailboxMessage[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MockMailboxMessageResponse = {
|
||||||
|
message: MockMailboxMessage;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MockMailboxFailureConfig = {
|
||||||
|
fail_next_smtp?: boolean | null;
|
||||||
|
fail_next_imap?: boolean | null;
|
||||||
|
smtp_reject_recipients_containing?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listMockMailboxMessages(settings: ApiSettings, kind?: string): Promise<MockMailboxListResponse> {
|
||||||
|
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : "";
|
||||||
|
return apiFetch<MockMailboxListResponse>(settings, `/api/v1/dev/mailbox/messages${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {
|
||||||
|
return apiFetch<MockMailboxMessageResponse>(settings, `/api/v1/dev/mailbox/messages/${encodeURIComponent(id)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearMockMailboxMessages(settings: ApiSettings): Promise<{ deleted_count: number }> {
|
||||||
|
return apiFetch<{ deleted_count: number }>(settings, "/api/v1/dev/mailbox/messages", { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMockMailboxFailures(settings: ApiSettings, payload: MockMailboxFailureConfig): Promise<{ config: MockMailboxFailureConfig }> {
|
||||||
|
return apiFetch<{ config: MockMailboxFailureConfig }>(settings, "/api/v1/dev/mailbox/failures", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
2
webui/src/components/Button.tsx
Normal file
2
webui/src/components/Button.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { Button } from "@govoplan/core-webui";
|
||||||
|
export default Button;
|
||||||
2
webui/src/components/Card.tsx
Normal file
2
webui/src/components/Card.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { Card } from "@govoplan/core-webui";
|
||||||
|
export default Card;
|
||||||
2
webui/src/components/ConfirmDialog.tsx
Normal file
2
webui/src/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
|
export default ConfirmDialog;
|
||||||
2
webui/src/components/Dialog.tsx
Normal file
2
webui/src/components/Dialog.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { Dialog } from "@govoplan/core-webui";
|
||||||
|
export default Dialog;
|
||||||
2
webui/src/components/DismissibleAlert.tsx
Normal file
2
webui/src/components/DismissibleAlert.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||||
|
export default DismissibleAlert;
|
||||||
2
webui/src/components/FormField.tsx
Normal file
2
webui/src/components/FormField.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { FormField } from "@govoplan/core-webui";
|
||||||
|
export default FormField;
|
||||||
2
webui/src/components/LoadingFrame.tsx
Normal file
2
webui/src/components/LoadingFrame.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { LoadingFrame } from "@govoplan/core-webui";
|
||||||
|
export default LoadingFrame;
|
||||||
2
webui/src/components/LoadingIndicator.tsx
Normal file
2
webui/src/components/LoadingIndicator.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { LoadingIndicator } from "@govoplan/core-webui";
|
||||||
|
export default LoadingIndicator;
|
||||||
2
webui/src/components/MetricCard.tsx
Normal file
2
webui/src/components/MetricCard.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { MetricCard } from "@govoplan/core-webui";
|
||||||
|
export default MetricCard;
|
||||||
2
webui/src/components/PageTitle.tsx
Normal file
2
webui/src/components/PageTitle.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { PageTitle } from "@govoplan/core-webui";
|
||||||
|
export default PageTitle;
|
||||||
2
webui/src/components/StatusBadge.tsx
Normal file
2
webui/src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
|
export default StatusBadge;
|
||||||
2
webui/src/components/Stepper.tsx
Normal file
2
webui/src/components/Stepper.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { Stepper } from "@govoplan/core-webui";
|
||||||
|
export default Stepper;
|
||||||
2
webui/src/components/ToggleSwitch.tsx
Normal file
2
webui/src/components/ToggleSwitch.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||||
|
export default ToggleSwitch;
|
||||||
2
webui/src/components/email/EmailAddressInput.tsx
Normal file
2
webui/src/components/email/EmailAddressInput.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { EmailAddressInput } from "@govoplan/core-webui";
|
||||||
|
export default EmailAddressInput;
|
||||||
2
webui/src/components/help/FieldLabel.tsx
Normal file
2
webui/src/components/help/FieldLabel.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { FieldLabel } from "@govoplan/core-webui";
|
||||||
|
export default FieldLabel;
|
||||||
2
webui/src/components/help/InlineHelp.tsx
Normal file
2
webui/src/components/help/InlineHelp.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { InlineHelp } from "@govoplan/core-webui";
|
||||||
|
export default InlineHelp;
|
||||||
4
webui/src/components/table/DataGrid.tsx
Normal file
4
webui/src/components/table/DataGrid.tsx
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { DataGrid, DataGridEmptyAction, DataGridRowActions } from "@govoplan/core-webui";
|
||||||
|
export type { DataGridColumn, DataGridListOption, DataGridPagination, DataGridQueryState, DataGridSortDirection } from "@govoplan/core-webui";
|
||||||
|
export { DataGridEmptyAction, DataGridRowActions };
|
||||||
|
export default DataGrid;
|
||||||
864
webui/src/features/mail/MailProfileManagement.tsx
Normal file
864
webui/src/features/mail/MailProfileManagement.tsx
Normal file
@@ -0,0 +1,864 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
|
import type { ApiSettings } from "../../types";
|
||||||
|
import {
|
||||||
|
createMailServerProfile,
|
||||||
|
deactivateMailServerProfile,
|
||||||
|
getMailProfilePolicy,
|
||||||
|
listMailServerProfiles,
|
||||||
|
mailProfilePatternKeys,
|
||||||
|
updateMailProfilePolicy,
|
||||||
|
updateMailServerProfile,
|
||||||
|
type MailCredentialPolicy,
|
||||||
|
type MailImapTestPayload,
|
||||||
|
type MailProfilePatternKey,
|
||||||
|
type MailProfilePatternRules,
|
||||||
|
type MailProfilePolicy,
|
||||||
|
type MailProfileScope,
|
||||||
|
type MailSecurity,
|
||||||
|
type MailServerProfile,
|
||||||
|
type MailServerProfilePayload,
|
||||||
|
type MailServerProfileUpdatePayload,
|
||||||
|
type MailSmtpTestPayload
|
||||||
|
} from "../../api/mail";
|
||||||
|
import Button from "../../components/Button";
|
||||||
|
import Card from "../../components/Card";
|
||||||
|
import ConfirmDialog from "../../components/ConfirmDialog";
|
||||||
|
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||||
|
import Dialog from "../../components/Dialog";
|
||||||
|
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||||
|
import FormField from "../../components/FormField";
|
||||||
|
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||||
|
|
||||||
|
export type MailProfileTargetOption = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
secondary?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProfileDraft = {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
isActive: boolean;
|
||||||
|
smtpHost: string;
|
||||||
|
smtpPort: string;
|
||||||
|
smtpSecurity: MailSecurity;
|
||||||
|
smtpUsername: string;
|
||||||
|
smtpPassword: string;
|
||||||
|
smtpTimeout: string;
|
||||||
|
imapEnabled: boolean;
|
||||||
|
imapHost: string;
|
||||||
|
imapPort: string;
|
||||||
|
imapSecurity: MailSecurity;
|
||||||
|
imapUsername: string;
|
||||||
|
imapPassword: string;
|
||||||
|
imapSentFolder: string;
|
||||||
|
imapTimeout: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MailProfileScopeManagerProps = {
|
||||||
|
settings: ApiSettings;
|
||||||
|
scopeType: MailProfileScope;
|
||||||
|
scopeId?: string | null;
|
||||||
|
targetOptions?: MailProfileTargetOption[];
|
||||||
|
targetLabel?: string;
|
||||||
|
profileTitle?: string;
|
||||||
|
policyTitle?: string;
|
||||||
|
canWriteProfiles: boolean;
|
||||||
|
canManageCredentials: boolean;
|
||||||
|
canWritePolicy: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MailProfilePolicyEditorProps = {
|
||||||
|
settings: ApiSettings;
|
||||||
|
scopeType: MailProfileScope;
|
||||||
|
scopeId?: string | null;
|
||||||
|
campaignId?: string | null;
|
||||||
|
profiles: MailServerProfile[];
|
||||||
|
ownerUserId?: string | null;
|
||||||
|
ownerGroupId?: string | null;
|
||||||
|
canWrite: boolean;
|
||||||
|
locked?: boolean;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
onSaved?: () => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EditingProfile = MailServerProfile | "new" | null;
|
||||||
|
type PolicyFlagValue = "inherit" | "allow" | "deny";
|
||||||
|
type CredentialInheritanceValue = "inherit" | "profile" | "local";
|
||||||
|
type CredentialOverrideValue = "inherit" | "allow" | "lock";
|
||||||
|
|
||||||
|
const securityOptions: MailSecurity[] = ["plain", "tls", "starttls"];
|
||||||
|
|
||||||
|
const patternLabels: Record<MailProfilePatternKey, string> = {
|
||||||
|
smtp_hosts: "SMTP hostnames",
|
||||||
|
imap_hosts: "IMAP hostnames",
|
||||||
|
envelope_senders: "Envelope senders",
|
||||||
|
from_headers: "From headers",
|
||||||
|
recipient_domains: "Recipient domains"
|
||||||
|
};
|
||||||
|
|
||||||
|
const blankPolicy: MailProfilePolicy = {
|
||||||
|
allowed_profile_ids: [],
|
||||||
|
allow_user_profiles: null,
|
||||||
|
allow_group_profiles: null,
|
||||||
|
allow_campaign_profiles: null,
|
||||||
|
smtp_credentials: {},
|
||||||
|
imap_credentials: {},
|
||||||
|
whitelist: {},
|
||||||
|
blacklist: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MailProfileScopeManager({
|
||||||
|
settings,
|
||||||
|
scopeType,
|
||||||
|
scopeId = null,
|
||||||
|
targetOptions = [],
|
||||||
|
targetLabel = "Target",
|
||||||
|
profileTitle = "Mail server profiles",
|
||||||
|
policyTitle = "Mail profile policy",
|
||||||
|
canWriteProfiles,
|
||||||
|
canManageCredentials,
|
||||||
|
canWritePolicy
|
||||||
|
}: MailProfileScopeManagerProps) {
|
||||||
|
const [profiles, setProfiles] = useState<MailServerProfile[]>([]);
|
||||||
|
const [selectedTargetId, setSelectedTargetId] = useState(scopeId || targetOptions[0]?.id || "");
|
||||||
|
const [editing, setEditing] = useState<EditingProfile>(null);
|
||||||
|
const [deactivating, setDeactivating] = useState<MailServerProfile | null>(null);
|
||||||
|
const [draft, setDraft] = useState<ProfileDraft>(emptyProfileDraft());
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
|
||||||
|
const requiresTarget = scopeType === "user" || scopeType === "group" || scopeType === "campaign";
|
||||||
|
const activeScopeId = scopeId || selectedTargetId || null;
|
||||||
|
const scopeReady = !requiresTarget || Boolean(activeScopeId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scopeId) {
|
||||||
|
setSelectedTargetId(scopeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (targetOptions.length > 0 && !targetOptions.some((option) => option.id === selectedTargetId)) {
|
||||||
|
setSelectedTargetId(targetOptions[0].id);
|
||||||
|
}
|
||||||
|
}, [scopeId, selectedTargetId, targetOptions]);
|
||||||
|
|
||||||
|
useEffect(() => { void loadProfiles(); }, [settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||||
|
|
||||||
|
async function loadProfiles() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
setProfiles(await listMailServerProfiles(settings, true));
|
||||||
|
} catch (err) {
|
||||||
|
setProfiles([]);
|
||||||
|
setError(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopedProfiles = useMemo(
|
||||||
|
() => profiles.filter((profile) => profileBelongsToEditableScope(profile, scopeType, activeScopeId)),
|
||||||
|
[activeScopeId, profiles, scopeType]
|
||||||
|
);
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setDraft(emptyProfileDraft());
|
||||||
|
setEditing("new");
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(profile: MailServerProfile) {
|
||||||
|
setDraft(profileToDraft(profile));
|
||||||
|
setEditing(profile);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProfile() {
|
||||||
|
if (!editing || !scopeReady) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
try {
|
||||||
|
if (editing === "new") {
|
||||||
|
const payload = createProfilePayload(draft, scopeType, activeScopeId);
|
||||||
|
const created = await createMailServerProfile(settings, payload);
|
||||||
|
setSuccess(`Profile ${created.name} created.`);
|
||||||
|
} else {
|
||||||
|
const payload = updateProfilePayload(draft, editing);
|
||||||
|
const updated = await updateMailServerProfile(settings, editing.id, payload);
|
||||||
|
setSuccess(`Profile ${updated.name} updated.`);
|
||||||
|
}
|
||||||
|
setEditing(null);
|
||||||
|
await loadProfiles();
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deactivateProfile() {
|
||||||
|
if (!deactivating) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
try {
|
||||||
|
await deactivateMailServerProfile(settings, deactivating.id);
|
||||||
|
setSuccess(`Profile ${deactivating.name} deactivated.`);
|
||||||
|
setDeactivating(null);
|
||||||
|
await loadProfiles();
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = useMemo<DataGridColumn<MailServerProfile>[]>(() => [
|
||||||
|
{ id: "name", header: "Profile", width: "minmax(220px, 1fr)", sticky: "start", resizable: true, sortable: true, filterable: true, value: (profile) => `${profile.name} ${profile.slug}`, render: (profile) => <div><strong>{profile.name}</strong><div className="muted small-note">{profile.slug}</div></div> },
|
||||||
|
{ id: "scope", header: "Scope", width: 120, resizable: true, sortable: true, filterable: true, value: (profile) => scopeLabel(profile) },
|
||||||
|
{ id: "smtp", header: "SMTP", width: 220, sortable: true, filterable: true, value: (profile) => transportLabel(profile.smtp), render: (profile) => <TransportCell profile={profile} protocol="smtp" /> },
|
||||||
|
{ id: "imap", header: "IMAP", width: 220, sortable: true, filterable: true, value: (profile) => profile.imap ? transportLabel(profile.imap) : "Not configured", render: (profile) => <TransportCell profile={profile} protocol="imap" /> },
|
||||||
|
{ id: "status", header: "Status", width: 110, sortable: true, filterable: true, value: (profile) => profile.is_active ? "Active" : "Inactive", render: (profile) => <span className={`status-badge ${profile.is_active ? "success" : "neutral"}`}>{profile.is_active ? "Active" : "Inactive"}</span> },
|
||||||
|
{ id: "actions", header: "Actions", width: 145, sticky: "end", align: "right", render: (profile) => <div className="admin-icon-actions">
|
||||||
|
<Button type="button" className="admin-icon-button" title={`Edit ${profile.name}`} aria-label={`Edit ${profile.name}`} onClick={() => openEdit(profile)} disabled={!canWriteProfiles || busy}><Pencil size={16} /></Button>
|
||||||
|
<Button type="button" variant="danger" className="admin-icon-button" title={`Deactivate ${profile.name}`} aria-label={`Deactivate ${profile.name}`} onClick={() => setDeactivating(profile)} disabled={!canWriteProfiles || busy || !profile.is_active}><Trash2 size={16} /></Button>
|
||||||
|
</div> }
|
||||||
|
], [busy, canWriteProfiles]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mail-profile-manager">
|
||||||
|
{targetOptions.length > 0 && !scopeId && (
|
||||||
|
<Card title={`${targetLabel} scope`}>
|
||||||
|
<div className="mail-profile-target-row">
|
||||||
|
<FormField label={targetLabel}>
|
||||||
|
<select value={selectedTargetId} onChange={(event) => setSelectedTargetId(event.target.value)} disabled={loading || busy}>
|
||||||
|
{targetOptions.map((option) => <option key={option.id} value={option.id}>{option.secondary ? `${option.label} (${option.secondary})` : option.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MailProfilePolicyEditor
|
||||||
|
settings={settings}
|
||||||
|
scopeType={scopeType}
|
||||||
|
scopeId={activeScopeId}
|
||||||
|
profiles={profiles}
|
||||||
|
canWrite={canWritePolicy}
|
||||||
|
title={policyTitle}
|
||||||
|
onSaved={loadProfiles}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title={profileTitle}
|
||||||
|
actions={
|
||||||
|
<div className="button-row compact-actions">
|
||||||
|
<Button onClick={() => void loadProfiles()} disabled={loading}>{loading ? "Loading…" : "Reload"}</Button>
|
||||||
|
<Button variant="primary" onClick={openCreate} disabled={!canWriteProfiles || !scopeReady || busy}><span className="button-icon-label"><Plus size={16} />New profile</span></Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="admin-table-surface mail-profile-table-surface">
|
||||||
|
<DataGrid
|
||||||
|
id={`mail-profiles-${scopeType}-${activeScopeId || "root"}`}
|
||||||
|
rows={scopeReady ? scopedProfiles : []}
|
||||||
|
columns={columns}
|
||||||
|
initialFit="container"
|
||||||
|
getRowKey={(profile) => profile.id}
|
||||||
|
emptyText={scopeReady ? "No profiles in this scope." : `Select a ${targetLabel.toLowerCase()} first.`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||||
|
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={editing !== null}
|
||||||
|
title={editing === "new" ? "Create mail profile" : "Edit mail profile"}
|
||||||
|
onClose={() => !busy && setEditing(null)}
|
||||||
|
className="admin-dialog admin-dialog-wide mail-profile-dialog"
|
||||||
|
closeDisabled={busy}
|
||||||
|
footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveProfile()} disabled={!canWriteProfiles || busy || !draft.name.trim() || !scopeReady}>{busy ? "Saving…" : "Save profile"}</Button></>}
|
||||||
|
>
|
||||||
|
<ProfileForm draft={draft} setDraft={setDraft} editing={editing} busy={busy} canWrite={canWriteProfiles} canManageCredentials={canManageCredentials} />
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(deactivating)}
|
||||||
|
title="Deactivate mail profile"
|
||||||
|
message={`Deactivate ${deactivating?.name || "this profile"}? Campaign drafts using it will need another allowed profile before sending.`}
|
||||||
|
confirmLabel="Deactivate"
|
||||||
|
tone="danger"
|
||||||
|
busy={busy}
|
||||||
|
onCancel={() => setDeactivating(null)}
|
||||||
|
onConfirm={() => void deactivateProfile()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MailProfilePolicyEditor({
|
||||||
|
settings,
|
||||||
|
scopeType,
|
||||||
|
scopeId = null,
|
||||||
|
campaignId = null,
|
||||||
|
profiles,
|
||||||
|
ownerUserId = null,
|
||||||
|
ownerGroupId = null,
|
||||||
|
canWrite,
|
||||||
|
locked = false,
|
||||||
|
title = "Mail profile policy",
|
||||||
|
description = "Allowed profiles and wildcard rules for this scope.",
|
||||||
|
onSaved
|
||||||
|
}: MailProfilePolicyEditorProps) {
|
||||||
|
const [policy, setPolicy] = useState<MailProfilePolicy>(blankPolicy);
|
||||||
|
const [effectivePolicy, setEffectivePolicy] = useState<MailProfilePolicy | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
|
||||||
|
const requiresTarget = scopeType === "user" || scopeType === "group" || scopeType === "campaign";
|
||||||
|
const scopeReady = !requiresTarget || Boolean(scopeId);
|
||||||
|
|
||||||
|
useEffect(() => { void loadPolicy(); }, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, scopeId, campaignId]);
|
||||||
|
|
||||||
|
async function loadPolicy() {
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
if (!scopeReady) {
|
||||||
|
setPolicy(blankPolicy);
|
||||||
|
setEffectivePolicy(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await getMailProfilePolicy(settings, scopeType, scopeId, campaignId);
|
||||||
|
setPolicy(normalizePolicy(response.policy));
|
||||||
|
setEffectivePolicy(response.effective_policy ? normalizePolicy(response.effective_policy) : null);
|
||||||
|
} catch (err) {
|
||||||
|
setPolicy(blankPolicy);
|
||||||
|
setEffectivePolicy(null);
|
||||||
|
setError(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePolicy() {
|
||||||
|
if (!scopeReady) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
try {
|
||||||
|
const response = await updateMailProfilePolicy(settings, scopeType, normalizePolicy(policy), scopeId);
|
||||||
|
setPolicy(normalizePolicy(response.policy));
|
||||||
|
setEffectivePolicy(response.effective_policy ? normalizePolicy(response.effective_policy) : null);
|
||||||
|
setSuccess("Mail profile policy saved.");
|
||||||
|
await onSaved?.();
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateProfiles = useMemo(
|
||||||
|
() => profileCandidatesForPolicy(profiles, scopeType, scopeId, ownerUserId, ownerGroupId),
|
||||||
|
[ownerGroupId, ownerUserId, profiles, scopeId, scopeType]
|
||||||
|
);
|
||||||
|
const selectedProfileIds = new Set(policy.allowed_profile_ids ?? []);
|
||||||
|
const disabled = locked || busy || loading || !canWrite || !scopeReady;
|
||||||
|
|
||||||
|
function patchPolicy(patch: Partial<MailProfilePolicy>) {
|
||||||
|
setPolicy((current) => normalizePolicy({ ...current, ...patch }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAllowedProfile(profileId: string, checked: boolean) {
|
||||||
|
const next = new Set(policy.allowed_profile_ids ?? []);
|
||||||
|
if (checked) next.add(profileId);
|
||||||
|
else next.delete(profileId);
|
||||||
|
patchPolicy({ allowed_profile_ids: [...next].sort() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFlag(key: "allow_user_profiles" | "allow_group_profiles" | "allow_campaign_profiles", value: PolicyFlagValue) {
|
||||||
|
patchPolicy({ [key]: flagToBoolean(value) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCredentialPolicy(protocol: "smtp" | "imap", patch: Partial<MailCredentialPolicy>) {
|
||||||
|
const key = protocol === "smtp" ? "smtp_credentials" : "imap_credentials";
|
||||||
|
patchPolicy({ [key]: normalizeCredentialPolicy({ ...(policy[key] ?? {}), ...patch }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPattern(kind: "whitelist" | "blacklist", key: MailProfilePatternKey, text: string) {
|
||||||
|
const nextRules = { ...(policy[kind] ?? {}) };
|
||||||
|
const parsed = parsePatternList(text);
|
||||||
|
if (parsed.length > 0) nextRules[key] = parsed;
|
||||||
|
else delete nextRules[key];
|
||||||
|
patchPolicy({ [kind]: nextRules });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={title}
|
||||||
|
actions={
|
||||||
|
<div className="button-row compact-actions">
|
||||||
|
<Button onClick={() => void loadPolicy()} disabled={loading || !scopeReady}>{loading ? "Loading…" : "Reload"}</Button>
|
||||||
|
<Button variant="primary" onClick={() => void savePolicy()} disabled={disabled}>{busy ? "Saving…" : "Save policy"}</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="mail-policy-editor">
|
||||||
|
{description && <p className="muted small-note mail-policy-description">{description}</p>}
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||||
|
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
||||||
|
|
||||||
|
<section className="mail-policy-section">
|
||||||
|
<div className="subsection-heading split">
|
||||||
|
<h3>Profile allow-list</h3>
|
||||||
|
<Button onClick={() => patchPolicy({ allowed_profile_ids: [] })} disabled={disabled || selectedProfileIds.size === 0}>Clear allow-list</Button>
|
||||||
|
</div>
|
||||||
|
<p className="muted small-note">{selectedProfileIds.size === 0 ? "No local profile allow-list is set." : `${selectedProfileIds.size} profile(s) allowed by this scope.`}</p>
|
||||||
|
<div className="mail-profile-checkbox-grid">
|
||||||
|
{candidateProfiles.map((profile) => (
|
||||||
|
<label className="mail-profile-checkbox" key={profile.id}>
|
||||||
|
<input type="checkbox" checked={selectedProfileIds.has(profile.id)} disabled={disabled} onChange={(event) => toggleAllowedProfile(profile.id, event.target.checked)} />
|
||||||
|
<span><strong>{profile.name}</strong><small>{scopeLabel(profile)} · {transportLabel(profile.smtp)}</small></span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{candidateProfiles.length === 0 && <p className="muted small-note">No profiles are visible for this policy scope.</p>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mail-policy-section">
|
||||||
|
<h3>Lower-level profile definitions</h3>
|
||||||
|
<div className="admin-form-grid three-columns mail-policy-flags">
|
||||||
|
<PolicyFlagSelect label="User profiles" value={booleanToFlag(policy.allow_user_profiles)} disabled={disabled} onChange={(value) => setFlag("allow_user_profiles", value)} />
|
||||||
|
<PolicyFlagSelect label="Group profiles" value={booleanToFlag(policy.allow_group_profiles)} disabled={disabled} onChange={(value) => setFlag("allow_group_profiles", value)} />
|
||||||
|
<PolicyFlagSelect label="Campaign profiles" value={booleanToFlag(policy.allow_campaign_profiles)} disabled={disabled} onChange={(value) => setFlag("allow_campaign_profiles", value)} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mail-policy-section">
|
||||||
|
<h3>Credential inheritance</h3>
|
||||||
|
<div className="admin-form-grid two-columns mail-policy-flags">
|
||||||
|
<CredentialInheritanceSelect label="SMTP credentials" value={credentialInheritanceToValue(policy.smtp_credentials?.inherit)} disabled={disabled} onChange={(value) => setCredentialPolicy("smtp", { inherit: valueToCredentialInheritance(value) })} />
|
||||||
|
<CredentialOverrideSelect label="SMTP override" value={credentialOverrideToValue(policy.smtp_credentials?.allow_override)} disabled={disabled} onChange={(value) => setCredentialPolicy("smtp", { allow_override: valueToCredentialOverride(value) })} />
|
||||||
|
<CredentialInheritanceSelect label="IMAP credentials" value={credentialInheritanceToValue(policy.imap_credentials?.inherit)} disabled={disabled} onChange={(value) => setCredentialPolicy("imap", { inherit: valueToCredentialInheritance(value) })} />
|
||||||
|
<CredentialOverrideSelect label="IMAP override" value={credentialOverrideToValue(policy.imap_credentials?.allow_override)} disabled={disabled} onChange={(value) => setCredentialPolicy("imap", { allow_override: valueToCredentialOverride(value) })} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mail-policy-section">
|
||||||
|
<h3>Wildcard rules</h3>
|
||||||
|
<div className="mail-policy-pattern-grid">
|
||||||
|
<div>
|
||||||
|
<h4>Whitelist</h4>
|
||||||
|
{mailProfilePatternKeys.map((key) => <PatternTextarea key={`whitelist-${key}`} label={patternLabels[key]} value={patternsToText(policy.whitelist?.[key])} disabled={disabled} onChange={(text) => setPattern("whitelist", key, text)} />)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>Blacklist</h4>
|
||||||
|
{mailProfilePatternKeys.map((key) => <PatternTextarea key={`blacklist-${key}`} label={patternLabels[key]} value={patternsToText(policy.blacklist?.[key])} disabled={disabled} onChange={(text) => setPattern("blacklist", key, text)} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{effectivePolicy && (
|
||||||
|
<section className="mail-policy-section mail-policy-effective">
|
||||||
|
<h3>Effective campaign policy</h3>
|
||||||
|
<div className="mail-policy-effective-grid">
|
||||||
|
<div><span>Profiles</span><strong>{effectiveProfileLabel(effectivePolicy.allowed_profile_ids)}</strong></div>
|
||||||
|
<div><span>User profiles</span><strong>{effectivePolicy.allow_user_profiles ? "Allowed" : "Blocked"}</strong></div>
|
||||||
|
<div><span>Group profiles</span><strong>{effectivePolicy.allow_group_profiles ? "Allowed" : "Blocked"}</strong></div>
|
||||||
|
<div><span>Campaign profiles</span><strong>{effectivePolicy.allow_campaign_profiles ? "Allowed" : "Blocked"}</strong></div>
|
||||||
|
<div><span>SMTP credentials</span><strong>{credentialPolicyLabel(effectivePolicy.smtp_credentials)}</strong></div>
|
||||||
|
<div><span>IMAP credentials</span><strong>{credentialPolicyLabel(effectivePolicy.imap_credentials)}</strong></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileForm({
|
||||||
|
draft,
|
||||||
|
setDraft,
|
||||||
|
editing,
|
||||||
|
busy,
|
||||||
|
canWrite,
|
||||||
|
canManageCredentials
|
||||||
|
}: {
|
||||||
|
draft: ProfileDraft;
|
||||||
|
setDraft: (draft: ProfileDraft) => void;
|
||||||
|
editing: EditingProfile;
|
||||||
|
busy: boolean;
|
||||||
|
canWrite: boolean;
|
||||||
|
canManageCredentials: boolean;
|
||||||
|
}) {
|
||||||
|
const disabled = busy || !canWrite;
|
||||||
|
const credentialDisabled = disabled || !canManageCredentials;
|
||||||
|
const existingHasImap = editing !== "new" && Boolean(editing?.imap);
|
||||||
|
const imapToggleDisabled = disabled || (!canManageCredentials && existingHasImap && draft.imapEnabled);
|
||||||
|
return (
|
||||||
|
<div className="mail-profile-form">
|
||||||
|
<div className="admin-form-grid two-columns">
|
||||||
|
<FormField label="Name"><input value={draft.name} disabled={disabled} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Slug"><input value={draft.slug} disabled={disabled} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} placeholder="Generated from name" /></FormField>
|
||||||
|
<FormField label="Status"><select value={draft.isActive ? "active" : "inactive"} disabled={disabled} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>
|
||||||
|
<FormField label="Description"><textarea rows={3} value={draft.description} disabled={disabled} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>SMTP</h3>
|
||||||
|
<div className="admin-form-grid three-columns">
|
||||||
|
<FormField label="Host"><input value={draft.smtpHost} disabled={disabled} onChange={(event) => setDraft({ ...draft, smtpHost: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Port"><input type="number" min={1} max={65535} value={draft.smtpPort} disabled={disabled} onChange={(event) => setDraft({ ...draft, smtpPort: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Security"><SecuritySelect value={draft.smtpSecurity} disabled={disabled} onChange={(smtpSecurity) => setDraft({ ...draft, smtpSecurity })} /></FormField>
|
||||||
|
<FormField label="Username"><input value={draft.smtpUsername} disabled={disabled} onChange={(event) => setDraft({ ...draft, smtpUsername: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Password" help={editing === "new" ? undefined : "Leave empty to keep the current password."}><input type="password" value={draft.smtpPassword} disabled={credentialDisabled} onChange={(event) => setDraft({ ...draft, smtpPassword: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Timeout seconds"><input type="number" min={1} value={draft.smtpTimeout} disabled={disabled} onChange={(event) => setDraft({ ...draft, smtpTimeout: event.target.value })} /></FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mail-profile-imap-heading">
|
||||||
|
<h3>IMAP</h3>
|
||||||
|
<ToggleSwitch label="Enable IMAP" checked={draft.imapEnabled} disabled={imapToggleDisabled} onChange={(imapEnabled) => setDraft({ ...draft, imapEnabled })} />
|
||||||
|
</div>
|
||||||
|
<div className="admin-form-grid three-columns">
|
||||||
|
<FormField label="Host"><input value={draft.imapHost} disabled={disabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapHost: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Port"><input type="number" min={1} max={65535} value={draft.imapPort} disabled={disabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapPort: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Security"><SecuritySelect value={draft.imapSecurity} disabled={disabled || !draft.imapEnabled} onChange={(imapSecurity) => setDraft({ ...draft, imapSecurity })} /></FormField>
|
||||||
|
<FormField label="Username"><input value={draft.imapUsername} disabled={disabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapUsername: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Password" help={editing === "new" ? undefined : "Leave empty to keep the current password."}><input type="password" value={draft.imapPassword} disabled={credentialDisabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapPassword: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Sent folder"><input value={draft.imapSentFolder} disabled={disabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapSentFolder: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Timeout seconds"><input type="number" min={1} value={draft.imapTimeout} disabled={disabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapTimeout: event.target.value })} /></FormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PolicyFlagSelect({ label, value, disabled, onChange }: { label: string; value: PolicyFlagValue; disabled: boolean; onChange: (value: PolicyFlagValue) => void }) {
|
||||||
|
return (
|
||||||
|
<FormField label={label}>
|
||||||
|
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as PolicyFlagValue)}>
|
||||||
|
<option value="inherit">Inherit</option>
|
||||||
|
<option value="allow">Explicit allow</option>
|
||||||
|
<option value="deny">Deny</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CredentialInheritanceSelect({ label, value, disabled, onChange }: { label: string; value: CredentialInheritanceValue; disabled: boolean; onChange: (value: CredentialInheritanceValue) => void }) {
|
||||||
|
return (
|
||||||
|
<FormField label={label}>
|
||||||
|
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as CredentialInheritanceValue)}>
|
||||||
|
<option value="inherit">Inherit parent decision</option>
|
||||||
|
<option value="profile">Inherit profile credentials</option>
|
||||||
|
<option value="local">Require local credentials</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CredentialOverrideSelect({ label, value, disabled, onChange }: { label: string; value: CredentialOverrideValue; disabled: boolean; onChange: (value: CredentialOverrideValue) => void }) {
|
||||||
|
return (
|
||||||
|
<FormField label={label}>
|
||||||
|
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as CredentialOverrideValue)}>
|
||||||
|
<option value="inherit">Inherit</option>
|
||||||
|
<option value="allow">Allow lower override</option>
|
||||||
|
<option value="lock">Lock lower levels</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PatternTextarea({ label, value, disabled, onChange }: { label: string; value: string; disabled: boolean; onChange: (value: string) => void }) {
|
||||||
|
return <FormField label={label}><textarea rows={3} value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)} placeholder="*.example.org" /></FormField>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SecuritySelect({ value, disabled, onChange }: { value: MailSecurity; disabled: boolean; onChange: (value: MailSecurity) => void }) {
|
||||||
|
return <select value={value} disabled={disabled} onChange={(event) => onChange(readSecurity(event.target.value, value))}>{securityOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TransportCell({ profile, protocol }: { profile: MailServerProfile; protocol: "smtp" | "imap" }) {
|
||||||
|
const transport = protocol === "smtp" ? profile.smtp : profile.imap;
|
||||||
|
if (!transport) return <span className="muted">Not configured</span>;
|
||||||
|
const hasPassword = protocol === "smtp" ? profile.smtp_password_configured : profile.imap_password_configured;
|
||||||
|
return <div><strong>{transportLabel(transport)}</strong><div className="muted small-note">Password {hasPassword ? "configured" : "not configured"}</div></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyProfileDraft(): ProfileDraft {
|
||||||
|
return {
|
||||||
|
name: "",
|
||||||
|
slug: "",
|
||||||
|
description: "",
|
||||||
|
isActive: true,
|
||||||
|
smtpHost: "",
|
||||||
|
smtpPort: "587",
|
||||||
|
smtpSecurity: "starttls",
|
||||||
|
smtpUsername: "",
|
||||||
|
smtpPassword: "",
|
||||||
|
smtpTimeout: "30",
|
||||||
|
imapEnabled: false,
|
||||||
|
imapHost: "",
|
||||||
|
imapPort: "993",
|
||||||
|
imapSecurity: "tls",
|
||||||
|
imapUsername: "",
|
||||||
|
imapPassword: "",
|
||||||
|
imapSentFolder: "auto",
|
||||||
|
imapTimeout: "30"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileToDraft(profile: MailServerProfile): ProfileDraft {
|
||||||
|
return {
|
||||||
|
name: profile.name,
|
||||||
|
slug: profile.slug,
|
||||||
|
description: profile.description || "",
|
||||||
|
isActive: profile.is_active,
|
||||||
|
smtpHost: stringValue(profile.smtp.host),
|
||||||
|
smtpPort: stringValue(profile.smtp.port ?? 587),
|
||||||
|
smtpSecurity: readSecurity(String(profile.smtp.security || "starttls"), "starttls"),
|
||||||
|
smtpUsername: stringValue(profile.smtp.username),
|
||||||
|
smtpPassword: "",
|
||||||
|
smtpTimeout: stringValue(profile.smtp.timeout_seconds ?? 30),
|
||||||
|
imapEnabled: Boolean(profile.imap),
|
||||||
|
imapHost: stringValue(profile.imap?.host),
|
||||||
|
imapPort: stringValue(profile.imap?.port ?? 993),
|
||||||
|
imapSecurity: readSecurity(String(profile.imap?.security || "tls"), "tls"),
|
||||||
|
imapUsername: stringValue(profile.imap?.username),
|
||||||
|
imapPassword: "",
|
||||||
|
imapSentFolder: stringValue(profile.imap?.sent_folder || "auto"),
|
||||||
|
imapTimeout: stringValue(profile.imap?.timeout_seconds ?? 30)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProfilePayload(draft: ProfileDraft, scopeType: MailProfileScope, scopeId: string | null): MailServerProfilePayload {
|
||||||
|
return {
|
||||||
|
name: draft.name.trim(),
|
||||||
|
slug: emptyToNull(draft.slug),
|
||||||
|
description: emptyToNull(draft.description),
|
||||||
|
is_active: draft.isActive,
|
||||||
|
scope_type: scopeType,
|
||||||
|
scope_id: scopeId,
|
||||||
|
smtp: smtpPayload(draft, false),
|
||||||
|
imap: draft.imapEnabled ? imapPayload(draft, false) : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProfilePayload(draft: ProfileDraft, profile: MailServerProfile): MailServerProfileUpdatePayload {
|
||||||
|
return {
|
||||||
|
name: draft.name.trim(),
|
||||||
|
slug: emptyToNull(draft.slug),
|
||||||
|
description: draft.description.trim(),
|
||||||
|
is_active: draft.isActive,
|
||||||
|
smtp: smtpPayload(draft, true),
|
||||||
|
imap: draft.imapEnabled ? imapPayload(draft, true) : null,
|
||||||
|
clear_imap: !draft.imapEnabled && Boolean(profile.imap)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function smtpPayload(draft: ProfileDraft, preserveBlankPassword: boolean): MailSmtpTestPayload {
|
||||||
|
const payload: MailSmtpTestPayload = {
|
||||||
|
host: emptyToNull(draft.smtpHost),
|
||||||
|
port: numberOrNull(draft.smtpPort),
|
||||||
|
username: emptyToNull(draft.smtpUsername),
|
||||||
|
security: draft.smtpSecurity,
|
||||||
|
timeout_seconds: numberOrDefault(draft.smtpTimeout, 30)
|
||||||
|
};
|
||||||
|
if (draft.smtpPassword || !preserveBlankPassword) payload.password = emptyToNull(draft.smtpPassword, false);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function imapPayload(draft: ProfileDraft, preserveBlankPassword: boolean): MailImapTestPayload {
|
||||||
|
const payload: MailImapTestPayload = {
|
||||||
|
enabled: true,
|
||||||
|
host: emptyToNull(draft.imapHost),
|
||||||
|
port: numberOrNull(draft.imapPort),
|
||||||
|
username: emptyToNull(draft.imapUsername),
|
||||||
|
security: draft.imapSecurity,
|
||||||
|
sent_folder: emptyToNull(draft.imapSentFolder) || "auto",
|
||||||
|
timeout_seconds: numberOrDefault(draft.imapTimeout, 30)
|
||||||
|
};
|
||||||
|
if (draft.imapPassword || !preserveBlankPassword) payload.password = emptyToNull(draft.imapPassword, false);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePolicy(value: MailProfilePolicy | null | undefined): MailProfilePolicy {
|
||||||
|
return {
|
||||||
|
allowed_profile_ids: [...(value?.allowed_profile_ids ?? [])].filter(Boolean),
|
||||||
|
allow_user_profiles: value?.allow_user_profiles ?? null,
|
||||||
|
allow_group_profiles: value?.allow_group_profiles ?? null,
|
||||||
|
allow_campaign_profiles: value?.allow_campaign_profiles ?? null,
|
||||||
|
smtp_credentials: normalizeCredentialPolicy(value?.smtp_credentials),
|
||||||
|
imap_credentials: normalizeCredentialPolicy(value?.imap_credentials),
|
||||||
|
whitelist: normalizeRules(value?.whitelist),
|
||||||
|
blacklist: normalizeRules(value?.blacklist)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCredentialPolicy(value: MailCredentialPolicy | null | undefined): MailCredentialPolicy {
|
||||||
|
return {
|
||||||
|
inherit: typeof value?.inherit === "boolean" ? value.inherit : null,
|
||||||
|
allow_override: typeof value?.allow_override === "boolean" ? value.allow_override : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRules(value: MailProfilePatternRules | null | undefined): MailProfilePatternRules {
|
||||||
|
const result: MailProfilePatternRules = {};
|
||||||
|
for (const key of mailProfilePatternKeys) {
|
||||||
|
const patterns = (value?.[key] ?? []).map((pattern) => pattern.trim()).filter(Boolean);
|
||||||
|
if (patterns.length > 0) result[key] = patterns;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileBelongsToEditableScope(profile: MailServerProfile, scopeType: MailProfileScope, scopeId: string | null): boolean {
|
||||||
|
if (profile.scope_type !== scopeType) return false;
|
||||||
|
if (scopeType === "system") return true;
|
||||||
|
if (scopeType === "tenant") return true;
|
||||||
|
return Boolean(scopeId) && profile.scope_id === scopeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileCandidatesForPolicy(profiles: MailServerProfile[], scopeType: MailProfileScope, scopeId: string | null, ownerUserId: string | null, ownerGroupId: string | null): MailServerProfile[] {
|
||||||
|
return profiles
|
||||||
|
.filter((profile) => {
|
||||||
|
if (scopeType === "system") return profile.scope_type === "system";
|
||||||
|
if (profile.scope_type === "system" || profile.scope_type === "tenant") return true;
|
||||||
|
if (scopeType === "user") return profile.scope_type === "user" && profile.scope_id === scopeId;
|
||||||
|
if (scopeType === "group") return profile.scope_type === "group" && profile.scope_id === scopeId;
|
||||||
|
if (scopeType === "campaign") {
|
||||||
|
if (profile.scope_type === "campaign") return profile.scope_id === scopeId;
|
||||||
|
if (profile.scope_type === "user") return Boolean(ownerUserId) && profile.scope_id === ownerUserId;
|
||||||
|
if (profile.scope_type === "group") return Boolean(ownerGroupId) && profile.scope_id === ownerGroupId;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
.sort((a, b) => `${scopeOrder(a.scope_type)}:${a.name}`.localeCompare(`${scopeOrder(b.scope_type)}:${b.name}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeOrder(scopeType: MailProfileScope): number {
|
||||||
|
if (scopeType === "system") return 0;
|
||||||
|
if (scopeType === "tenant") return 1;
|
||||||
|
if (scopeType === "user" || scopeType === "group") return 2;
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
function credentialInheritanceToValue(value: boolean | null | undefined): CredentialInheritanceValue {
|
||||||
|
if (value === true) return "profile";
|
||||||
|
if (value === false) return "local";
|
||||||
|
return "inherit";
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueToCredentialInheritance(value: CredentialInheritanceValue): boolean | null {
|
||||||
|
if (value === "profile") return true;
|
||||||
|
if (value === "local") return false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function credentialOverrideToValue(value: boolean | null | undefined): CredentialOverrideValue {
|
||||||
|
if (value === true) return "allow";
|
||||||
|
if (value === false) return "lock";
|
||||||
|
return "inherit";
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueToCredentialOverride(value: CredentialOverrideValue): boolean | null {
|
||||||
|
if (value === "allow") return true;
|
||||||
|
if (value === "lock") return false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function credentialPolicyLabel(value: MailCredentialPolicy | null | undefined): string {
|
||||||
|
const source = value?.inherit === false ? "Local required" : "Profile inherited";
|
||||||
|
const override = value?.allow_override === false ? "locked" : "override allowed";
|
||||||
|
return `${source}, ${override}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function booleanToFlag(value: boolean | null | undefined): PolicyFlagValue {
|
||||||
|
if (value === true) return "allow";
|
||||||
|
if (value === false) return "deny";
|
||||||
|
return "inherit";
|
||||||
|
}
|
||||||
|
|
||||||
|
function flagToBoolean(value: PolicyFlagValue): boolean | null {
|
||||||
|
if (value === "allow") return true;
|
||||||
|
if (value === "deny") return false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePatternList(value: string): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: string[] = [];
|
||||||
|
for (const item of value.split(/[\n,]+/)) {
|
||||||
|
const pattern = item.trim();
|
||||||
|
if (pattern && !seen.has(pattern)) {
|
||||||
|
seen.add(pattern);
|
||||||
|
result.push(pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function patternsToText(value: string[] | undefined): string {
|
||||||
|
return (value ?? []).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectiveProfileLabel(value: string[] | null | undefined): string {
|
||||||
|
if (!value || value.length === 0) return "No explicit intersection";
|
||||||
|
return `${value.length} profile(s)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transportLabel(transport: MailSmtpTestPayload | MailImapTestPayload | null | undefined): string {
|
||||||
|
if (!transport) return "Not configured";
|
||||||
|
const host = transport.host || "No host";
|
||||||
|
const port = transport.port ? `:${transport.port}` : "";
|
||||||
|
return `${host}${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeLabel(profile: MailServerProfile): string {
|
||||||
|
if (profile.scope_type === "system") return "System";
|
||||||
|
if (profile.scope_type === "tenant") return "Tenant";
|
||||||
|
if (profile.scope_type === "user") return "User";
|
||||||
|
if (profile.scope_type === "group") return "Group";
|
||||||
|
return "Campaign";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSecurity(value: string, fallback: MailSecurity): MailSecurity {
|
||||||
|
return securityOptions.includes(value as MailSecurity) ? (value as MailSecurity) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyToNull(value: string, trim = true): string | null {
|
||||||
|
const normalized = trim ? value.trim() : value;
|
||||||
|
return normalized ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return "";
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberOrNull(value: string): number | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const parsed = Number(trimmed);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberOrDefault(value: string, fallback: number): number {
|
||||||
|
const parsed = numberOrNull(value);
|
||||||
|
return parsed ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(err: unknown): string {
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
7
webui/src/index.ts
Normal file
7
webui/src/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import "./styles/mail-profiles.css";
|
||||||
|
export { default } from "./module";
|
||||||
|
export * from "./module";
|
||||||
|
export * from "./api/mail";
|
||||||
|
export { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement";
|
||||||
|
export type { MailProfileTargetOption } from "./features/mail/MailProfileManagement";
|
||||||
|
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";
|
||||||
10
webui/src/module.ts
Normal file
10
webui/src/module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export const mailModule: PlatformWebModule = {
|
||||||
|
id: "mail",
|
||||||
|
label: "Mail",
|
||||||
|
version: "1.0.0",
|
||||||
|
dependencies: ["access"]
|
||||||
|
};
|
||||||
|
|
||||||
|
export default mailModule;
|
||||||
195
webui/src/styles/mail-profiles.css
Normal file
195
webui/src/styles/mail-profiles.css
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
.admin-form-grid.three-columns {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-icon-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-profile-manager {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.mail-profile-target-row {
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-profile-table-surface {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-profile-table-surface > .data-grid-shell {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body > .mail-profile-table-surface:only-child {
|
||||||
|
margin: -22px -24px;
|
||||||
|
width: calc(100% + 48px);
|
||||||
|
max-width: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body > .mail-profile-table-surface:only-child > .data-grid-shell {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-profile-dialog .dialog-body {
|
||||||
|
max-height: min(76vh, 820px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-profile-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-profile-form h3,
|
||||||
|
.mail-policy-section h3,
|
||||||
|
.mail-policy-pattern-grid h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-profile-imap-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-credential-inheritance-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(220px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 760px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-policy-editor {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-policy-description {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-policy-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-profile-checkbox-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-profile-checkbox {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-profile-checkbox input {
|
||||||
|
width: auto;
|
||||||
|
margin-top: 3px;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-profile-checkbox span {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-profile-checkbox small {
|
||||||
|
color: var(--muted);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-policy-pattern-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-policy-pattern-grid > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-policy-effective {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-policy-effective-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-policy-effective-grid div {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-policy-effective-grid span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-policy-effective-grid strong {
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.success {
|
||||||
|
background: var(--success-bg);
|
||||||
|
color: var(--success-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.neutral {
|
||||||
|
background: #e7e4df;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.admin-form-grid.three-columns,
|
||||||
|
.mail-policy-pattern-grid,
|
||||||
|
.mail-policy-effective-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.mail-credential-inheritance-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-profile-imap-heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
webui/src/types.ts
Normal file
1
webui/src/types.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export type { ApiSettings, AuthInfo, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext, PlatformWebModule } from "@govoplan/core-webui";
|
||||||
Reference in New Issue
Block a user