Add hierarchical mail servers and credentials
This commit is contained in:
@@ -11,6 +11,7 @@ from govoplan_mail.backend.mail_profiles import (
|
||||
_assert_campaign_inherits_profile_credentials,
|
||||
assert_campaign_mail_policy_allows_json,
|
||||
assert_mail_policy_allows_send,
|
||||
campaign_mail_owner_context,
|
||||
campaign_profile_transport_revisions,
|
||||
effective_mail_profile_policy,
|
||||
ensure_mail_profile_allowed_for_campaign,
|
||||
@@ -19,6 +20,12 @@ from govoplan_mail.backend.mail_profiles import (
|
||||
mail_profile_id_from_campaign_json,
|
||||
smtp_config_from_profile,
|
||||
)
|
||||
from govoplan_mail.backend.server_hierarchy import (
|
||||
MailHierarchyContext,
|
||||
MailServerHierarchyError,
|
||||
resolve_mail_transport,
|
||||
select_mail_transport,
|
||||
)
|
||||
from govoplan_mail.backend.runtime import configure_runtime
|
||||
from govoplan_mail.backend.sending.imap import (
|
||||
ImapAppendError,
|
||||
@@ -104,6 +111,7 @@ def _authorized_campaign_profile(
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
profile_id: str,
|
||||
selection: dict[str, str | None] | None = None,
|
||||
):
|
||||
profile = ensure_mail_profile_allowed_for_campaign(
|
||||
session,
|
||||
@@ -113,10 +121,55 @@ def _authorized_campaign_profile(
|
||||
require_active=True,
|
||||
)
|
||||
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id)
|
||||
_assert_campaign_inherits_profile_credentials(profile, policy)
|
||||
_assert_campaign_inherits_profile_credentials(profile, policy, selection)
|
||||
return profile
|
||||
|
||||
|
||||
def _campaign_hierarchy_context(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
) -> MailHierarchyContext:
|
||||
campaign = campaign_mail_owner_context(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
)
|
||||
return MailHierarchyContext(
|
||||
tenant_id=tenant_id,
|
||||
user_id=campaign.owner_user_id,
|
||||
group_ids=(
|
||||
frozenset({campaign.owner_group_id})
|
||||
if campaign.owner_group_id
|
||||
else frozenset()
|
||||
),
|
||||
target_scope_type="campaign",
|
||||
target_scope_id=campaign.id,
|
||||
)
|
||||
|
||||
|
||||
def _selection_payload(
|
||||
*,
|
||||
profile_id: str,
|
||||
smtp_server_id: str | None = None,
|
||||
smtp_credential_id: str | None = None,
|
||||
imap_server_id: str | None = None,
|
||||
imap_credential_id: str | None = None,
|
||||
) -> dict[str, str | None]:
|
||||
return {
|
||||
"mail_profile_id": profile_id,
|
||||
"smtp_server_id": smtp_server_id,
|
||||
"smtp_credential_id": smtp_credential_id,
|
||||
"imap_server_id": imap_server_id,
|
||||
"imap_credential_id": imap_credential_id,
|
||||
}
|
||||
|
||||
|
||||
def _supports_hierarchy(session: object) -> bool:
|
||||
return callable(getattr(session, "execute", None))
|
||||
|
||||
|
||||
def campaign_profile_delivery_summary(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -125,21 +178,33 @@ def campaign_profile_delivery_summary(
|
||||
campaign_id: str | None = None,
|
||||
owner_user_id: str | None = None,
|
||||
owner_group_id: str | None = None,
|
||||
smtp_server_id: str | None = None,
|
||||
smtp_credential_id: str | None = None,
|
||||
imap_server_id: str | None = None,
|
||||
imap_credential_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return only non-secret capabilities and opaque drift evidence."""
|
||||
|
||||
selection = _selection_payload(
|
||||
profile_id=profile_id,
|
||||
smtp_server_id=smtp_server_id,
|
||||
smtp_credential_id=smtp_credential_id,
|
||||
imap_server_id=imap_server_id,
|
||||
imap_credential_id=imap_credential_id,
|
||||
)
|
||||
if campaign_id:
|
||||
profile = _authorized_campaign_profile(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
profile_id=profile_id,
|
||||
selection=selection,
|
||||
)
|
||||
else:
|
||||
assert_campaign_mail_policy_allows_json(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
raw_json={"server": {"mail_profile_id": profile_id}},
|
||||
raw_json={"server": {key: value for key, value in selection.items() if value}},
|
||||
owner_user_id=owner_user_id,
|
||||
owner_group_id=owner_group_id,
|
||||
)
|
||||
@@ -149,15 +214,61 @@ def campaign_profile_delivery_summary(
|
||||
profile_id=profile_id,
|
||||
require_active=True,
|
||||
)
|
||||
revisions = campaign_profile_transport_revisions(profile)
|
||||
smtp = profile.smtp_config or {}
|
||||
imap = profile.imap_config or {}
|
||||
if campaign_id and _supports_hierarchy(session):
|
||||
context = _campaign_hierarchy_context(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
)
|
||||
try:
|
||||
smtp = select_mail_transport(
|
||||
session,
|
||||
profile=profile,
|
||||
protocol="smtp",
|
||||
context=context,
|
||||
server_id=smtp_server_id,
|
||||
credential_id=smtp_credential_id,
|
||||
)
|
||||
imap = select_mail_transport(
|
||||
session,
|
||||
profile=profile,
|
||||
protocol="imap",
|
||||
context=context,
|
||||
server_id=imap_server_id,
|
||||
credential_id=imap_credential_id,
|
||||
)
|
||||
except MailServerHierarchyError as exc:
|
||||
raise MailProfileError(str(exc)) from exc
|
||||
smtp_available = smtp.available
|
||||
imap_available = imap.available
|
||||
smtp_revision = smtp.transport_revision
|
||||
imap_revision = imap.transport_revision if imap.available else None
|
||||
resolved_smtp_server_id = smtp.server.id if smtp.server else None
|
||||
resolved_smtp_credential_id = smtp.credential.id if smtp.credential else None
|
||||
resolved_imap_server_id = imap.server.id if imap.server else None
|
||||
resolved_imap_credential_id = imap.credential.id if imap.credential else None
|
||||
else:
|
||||
revisions = campaign_profile_transport_revisions(profile)
|
||||
smtp_config = profile.smtp_config or {}
|
||||
imap_config = profile.imap_config or {}
|
||||
smtp_available = bool(smtp_config.get("host") and smtp_config.get("port"))
|
||||
imap_available = bool(imap_config.get("host") and imap_config.get("port"))
|
||||
smtp_revision = revisions["smtp"]
|
||||
imap_revision = revisions["imap"]
|
||||
resolved_smtp_server_id = smtp_server_id
|
||||
resolved_smtp_credential_id = smtp_credential_id
|
||||
resolved_imap_server_id = imap_server_id
|
||||
resolved_imap_credential_id = imap_credential_id
|
||||
return {
|
||||
"mail_profile_id": profile_id,
|
||||
"smtp_available": bool(smtp.get("host") and smtp.get("port")),
|
||||
"imap_available": bool(imap.get("host") and imap.get("port")),
|
||||
"smtp_transport_revision": revisions["smtp"],
|
||||
"imap_transport_revision": revisions["imap"],
|
||||
"smtp_server_id": resolved_smtp_server_id,
|
||||
"smtp_credential_id": resolved_smtp_credential_id,
|
||||
"imap_server_id": resolved_imap_server_id,
|
||||
"imap_credential_id": resolved_imap_credential_id,
|
||||
"smtp_available": smtp_available,
|
||||
"imap_available": imap_available,
|
||||
"smtp_transport_revision": smtp_revision,
|
||||
"imap_transport_revision": imap_revision,
|
||||
}
|
||||
|
||||
|
||||
@@ -172,26 +283,65 @@ def send_campaign_email_bytes(
|
||||
envelope_recipients: list[str],
|
||||
from_header: str | None,
|
||||
expected_smtp_transport_revision: str,
|
||||
smtp_server_id: str | None = None,
|
||||
smtp_credential_id: str | None = None,
|
||||
) -> CampaignSmtpDeliveryResult:
|
||||
selection = _selection_payload(
|
||||
profile_id=profile_id,
|
||||
smtp_server_id=smtp_server_id,
|
||||
smtp_credential_id=smtp_credential_id,
|
||||
)
|
||||
try:
|
||||
profile = _authorized_campaign_profile(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
profile_id=profile_id,
|
||||
selection=selection,
|
||||
)
|
||||
except MailProfileError:
|
||||
raise
|
||||
except Exception:
|
||||
raise SmtpConfigurationError("The selected Mail profile's SMTP configuration is unusable.") from None
|
||||
revisions = campaign_profile_transport_revisions(profile)
|
||||
if revisions["smtp"] != expected_smtp_transport_revision:
|
||||
resolved_smtp = None
|
||||
if _supports_hierarchy(session):
|
||||
context = _campaign_hierarchy_context(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
)
|
||||
try:
|
||||
selected_smtp = select_mail_transport(
|
||||
session,
|
||||
profile=profile,
|
||||
protocol="smtp",
|
||||
context=context,
|
||||
server_id=smtp_server_id,
|
||||
credential_id=smtp_credential_id,
|
||||
)
|
||||
except MailServerHierarchyError as exc:
|
||||
raise MailProfileError(str(exc)) from exc
|
||||
current_smtp_revision = selected_smtp.transport_revision
|
||||
else:
|
||||
current_smtp_revision = campaign_profile_transport_revisions(profile)["smtp"]
|
||||
if current_smtp_revision != expected_smtp_transport_revision:
|
||||
raise MailProfileError(
|
||||
"The selected Mail profile's SMTP settings changed after this campaign was built. "
|
||||
"Revalidate and rebuild the campaign before delivery."
|
||||
)
|
||||
try:
|
||||
smtp = smtp_config_from_profile(profile)
|
||||
if _supports_hierarchy(session):
|
||||
resolved_smtp = resolve_mail_transport(
|
||||
session,
|
||||
profile=profile,
|
||||
protocol="smtp",
|
||||
context=context,
|
||||
server_id=smtp_server_id,
|
||||
credential_id=smtp_credential_id,
|
||||
)
|
||||
smtp = resolved_smtp.config
|
||||
else:
|
||||
smtp = smtp_config_from_profile(profile)
|
||||
except MailProfileError:
|
||||
raise
|
||||
except Exception:
|
||||
@@ -202,7 +352,7 @@ def send_campaign_email_bytes(
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
smtp=smtp,
|
||||
imap=profile.imap_config or None,
|
||||
imap=None,
|
||||
envelope_sender=envelope_from,
|
||||
from_header=from_header,
|
||||
recipients=envelope_recipients,
|
||||
@@ -241,31 +391,83 @@ def append_campaign_message_to_sent(
|
||||
folder: str | None,
|
||||
expected_smtp_transport_revision: str,
|
||||
expected_imap_transport_revision: str | None,
|
||||
smtp_server_id: str | None = None,
|
||||
smtp_credential_id: str | None = None,
|
||||
imap_server_id: str | None = None,
|
||||
imap_credential_id: str | None = None,
|
||||
) -> CampaignImapAppendResult:
|
||||
selection = _selection_payload(
|
||||
profile_id=profile_id,
|
||||
smtp_server_id=smtp_server_id,
|
||||
smtp_credential_id=smtp_credential_id,
|
||||
imap_server_id=imap_server_id,
|
||||
imap_credential_id=imap_credential_id,
|
||||
)
|
||||
try:
|
||||
profile = _authorized_campaign_profile(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
profile_id=profile_id,
|
||||
selection=selection,
|
||||
)
|
||||
except MailProfileError:
|
||||
raise
|
||||
except Exception:
|
||||
raise ImapConfigurationError("The selected Mail profile's IMAP configuration is unusable.") from None
|
||||
revisions = campaign_profile_transport_revisions(profile)
|
||||
if revisions["smtp"] != expected_smtp_transport_revision:
|
||||
if _supports_hierarchy(session):
|
||||
context = _campaign_hierarchy_context(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
)
|
||||
try:
|
||||
selected_smtp = select_mail_transport(
|
||||
session,
|
||||
profile=profile,
|
||||
protocol="smtp",
|
||||
context=context,
|
||||
server_id=smtp_server_id,
|
||||
credential_id=smtp_credential_id,
|
||||
)
|
||||
selected_imap = select_mail_transport(
|
||||
session,
|
||||
profile=profile,
|
||||
protocol="imap",
|
||||
context=context,
|
||||
server_id=imap_server_id,
|
||||
credential_id=imap_credential_id,
|
||||
)
|
||||
except MailServerHierarchyError as exc:
|
||||
raise MailProfileError(str(exc)) from exc
|
||||
smtp_revision = selected_smtp.transport_revision
|
||||
imap_revision = selected_imap.transport_revision
|
||||
else:
|
||||
revisions = campaign_profile_transport_revisions(profile)
|
||||
smtp_revision = revisions["smtp"]
|
||||
imap_revision = revisions["imap"]
|
||||
if smtp_revision != expected_smtp_transport_revision:
|
||||
raise MailProfileError(
|
||||
"The selected Mail profile's SMTP settings changed after this campaign was built. "
|
||||
"Revalidate and rebuild the campaign before append-to-Sent delivery."
|
||||
)
|
||||
if revisions["imap"] != expected_imap_transport_revision:
|
||||
if imap_revision != expected_imap_transport_revision:
|
||||
raise MailProfileError(
|
||||
"The selected Mail profile's IMAP settings changed after this campaign was built. "
|
||||
"Revalidate and rebuild the campaign before append-to-Sent delivery."
|
||||
)
|
||||
try:
|
||||
imap = imap_config_from_profile(profile)
|
||||
if _supports_hierarchy(session):
|
||||
imap = resolve_mail_transport(
|
||||
session,
|
||||
profile=profile,
|
||||
protocol="imap",
|
||||
context=context,
|
||||
server_id=imap_server_id,
|
||||
credential_id=imap_credential_id,
|
||||
).config
|
||||
else:
|
||||
imap = imap_config_from_profile(profile)
|
||||
except MailProfileError:
|
||||
raise
|
||||
except Exception:
|
||||
@@ -277,7 +479,7 @@ def append_campaign_message_to_sent(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
smtp=profile.smtp_config or None,
|
||||
smtp=None,
|
||||
imap=imap,
|
||||
)
|
||||
except MailProfileError:
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from govoplan_core.db.base import Base, TimestampMixin
|
||||
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
@@ -30,6 +31,7 @@ class MailServerProfile(Base, TimestampMixin):
|
||||
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)
|
||||
inherit_to_lower_scopes: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
smtp_config: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
smtp_username: Mapped[str | None] = mapped_column(String(320))
|
||||
smtp_password_encrypted: Mapped[str | None] = mapped_column(Text)
|
||||
@@ -42,6 +44,56 @@ class MailServerProfile(Base, TimestampMixin):
|
||||
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
|
||||
class MailServerEndpoint(Base, TimestampMixin):
|
||||
__tablename__ = "mail_server_endpoints"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("profile_id", "protocol", "name", name="uq_mail_server_endpoints_profile_protocol_name"),
|
||||
Index("ix_mail_server_endpoints_profile_protocol", "profile_id", "protocol", "is_active"),
|
||||
Index("ix_mail_server_endpoints_scope", "tenant_id", "scope_type", "scope_id"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
profile_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("mail_server_profiles.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
protocol: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
config: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
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)
|
||||
inherit_to_lower_scopes: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
||||
transport_revision: Mapped[str] = mapped_column(String(36), default=new_uuid, nullable=False)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
|
||||
class MailServerCredentialBinding(Base, TimestampMixin):
|
||||
__tablename__ = "mail_server_credential_bindings"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("server_id", "credential_id", name="uq_mail_server_credential_bindings_server_credential"),
|
||||
Index("ix_mail_server_credential_bindings_default", "server_id", "is_default"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
server_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("mail_server_endpoints.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
credential_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("core_credential_envelopes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
|
||||
class MailProfilePolicy(Base, TimestampMixin):
|
||||
__tablename__ = "mail_profile_policies"
|
||||
__table_args__ = (
|
||||
|
||||
@@ -4,7 +4,7 @@ import fnmatch
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Iterable
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
from sqlalchemy import and_, or_, select, text
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -919,6 +919,19 @@ def campaign_mail_context_visible_to_actor(
|
||||
)
|
||||
|
||||
|
||||
def campaign_mail_owner_context(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
) -> CampaignMailPolicyContext:
|
||||
return _campaign_policy_context(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
)
|
||||
|
||||
|
||||
def mail_profile_scope_visible_to_actor(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -1335,7 +1348,15 @@ def _profile_has_transport(profile: MailServerProfile, protocol: str) -> bool:
|
||||
return bool(profile.imap_config)
|
||||
|
||||
|
||||
_CAMPAIGN_MAIL_REFERENCE_KEYS = frozenset({"mail_profile_id"})
|
||||
_CAMPAIGN_MAIL_REFERENCE_KEYS = frozenset(
|
||||
{
|
||||
"mail_profile_id",
|
||||
"smtp_server_id",
|
||||
"smtp_credential_id",
|
||||
"imap_server_id",
|
||||
"imap_credential_id",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _campaign_mail_profile_reference_id(server: dict[str, Any]) -> str | None:
|
||||
@@ -1343,8 +1364,8 @@ def _campaign_mail_profile_reference_id(server: dict[str, Any]) -> str | None:
|
||||
if unexpected:
|
||||
paths = ", ".join(f"server.{key}" for key in unexpected)
|
||||
raise MailProfileError(
|
||||
"Campaign JSON may only reference a Mail-owned profile through server.mail_profile_id; "
|
||||
f"remove campaign-local SMTP/IMAP settings ({paths}), select a Mail profile, and save a new campaign version."
|
||||
"Campaign JSON may only reference Mail-owned profile, server, and credential identifiers; "
|
||||
f"remove campaign-local SMTP/IMAP settings ({paths}), select Mail resources, and save a new campaign version."
|
||||
)
|
||||
value = server.get("mail_profile_id")
|
||||
if value is None:
|
||||
@@ -1354,18 +1375,63 @@ def _campaign_mail_profile_reference_id(server: dict[str, Any]) -> str | None:
|
||||
return value.strip()
|
||||
|
||||
|
||||
def campaign_mail_selection_from_json(
|
||||
raw_json: dict[str, Any] | None,
|
||||
) -> dict[str, str | None]:
|
||||
data = raw_json if isinstance(raw_json, dict) else {}
|
||||
server = data.get("server") if isinstance(data.get("server"), dict) else {}
|
||||
profile_id = _campaign_mail_profile_reference_id(server)
|
||||
selection: dict[str, str | None] = {"mail_profile_id": profile_id}
|
||||
for key in (
|
||||
"smtp_server_id",
|
||||
"smtp_credential_id",
|
||||
"imap_server_id",
|
||||
"imap_credential_id",
|
||||
):
|
||||
value = server.get(key)
|
||||
if value is None:
|
||||
selection[key] = None
|
||||
continue
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise MailProfileError(f"server.{key} must be a non-empty Mail identifier")
|
||||
selection[key] = value.strip()
|
||||
if profile_id is None and any(
|
||||
selection[key]
|
||||
for key in selection
|
||||
if key != "mail_profile_id"
|
||||
):
|
||||
raise MailProfileError(
|
||||
"Mail server or credential selections require server.mail_profile_id"
|
||||
)
|
||||
for protocol in ("smtp", "imap"):
|
||||
if (
|
||||
selection[f"{protocol}_credential_id"]
|
||||
and not selection[f"{protocol}_server_id"]
|
||||
):
|
||||
raise MailProfileError(
|
||||
f"server.{protocol}_credential_id requires server.{protocol}_server_id"
|
||||
)
|
||||
return selection
|
||||
|
||||
|
||||
def _assert_campaign_inherits_profile_credentials(
|
||||
profile: MailServerProfile,
|
||||
policy: EffectiveMailProfilePolicy,
|
||||
selection: Mapping[str, str | None] | None = None,
|
||||
) -> None:
|
||||
for protocol in ("smtp", "imap"):
|
||||
if not _profile_has_transport(profile, protocol):
|
||||
continue
|
||||
if not _credential_policy_for_protocol(policy, protocol).inherit:
|
||||
explicit_credential = (
|
||||
selection or {}
|
||||
).get(f"{protocol}_credential_id")
|
||||
if (
|
||||
not _credential_policy_for_protocol(policy, protocol).inherit
|
||||
and not explicit_credential
|
||||
):
|
||||
raise MailProfileError(
|
||||
f"Campaign delivery cannot use the selected profile because the effective {protocol.upper()} "
|
||||
"credential policy requires campaign-local credentials. Store the credentials on a Mail profile "
|
||||
"and change the policy to inherit them."
|
||||
"credential policy requires an explicit credential selection for this campaign."
|
||||
)
|
||||
|
||||
|
||||
@@ -1379,8 +1445,8 @@ def assert_campaign_mail_policy_allows_json(
|
||||
owner_group_id: str | None = None,
|
||||
) -> None:
|
||||
data = raw_json if isinstance(raw_json, dict) else {}
|
||||
server = data.get("server") if isinstance(data.get("server"), dict) else {}
|
||||
profile_id = _campaign_mail_profile_reference_id(server)
|
||||
selection = campaign_mail_selection_from_json(data)
|
||||
profile_id = selection["mail_profile_id"]
|
||||
if profile_id:
|
||||
if campaign_id:
|
||||
profile = ensure_mail_profile_allowed_for_campaign(
|
||||
@@ -1391,7 +1457,7 @@ def assert_campaign_mail_policy_allows_json(
|
||||
require_active=True,
|
||||
)
|
||||
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id)
|
||||
_assert_campaign_inherits_profile_credentials(profile, policy)
|
||||
_assert_campaign_inherits_profile_credentials(profile, policy, selection)
|
||||
return
|
||||
profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=str(profile_id), require_active=True)
|
||||
policy = effective_mail_profile_policy(
|
||||
@@ -1408,7 +1474,7 @@ def assert_campaign_mail_policy_allows_json(
|
||||
owner_group_id=owner_group_id,
|
||||
):
|
||||
raise MailProfileError("Mail-server profile is not allowed by the effective policy")
|
||||
_assert_campaign_inherits_profile_credentials(profile, policy)
|
||||
_assert_campaign_inherits_profile_credentials(profile, policy, selection)
|
||||
return
|
||||
return
|
||||
|
||||
@@ -1424,6 +1490,7 @@ def create_mail_server_profile(
|
||||
smtp: SmtpConfig,
|
||||
imap: ImapConfig | None,
|
||||
is_active: bool = True,
|
||||
inherit_to_lower_scopes: bool = True,
|
||||
scope_type: str = "tenant",
|
||||
scope_id: str | None = None,
|
||||
) -> MailServerProfile:
|
||||
@@ -1466,6 +1533,7 @@ def create_mail_server_profile(
|
||||
slug=clean_slug,
|
||||
description=description,
|
||||
is_active=is_active,
|
||||
inherit_to_lower_scopes=bool(inherit_to_lower_scopes),
|
||||
smtp_config=smtp_payload,
|
||||
smtp_username=smtp_username,
|
||||
smtp_password_encrypted=encrypt_secret(smtp_password),
|
||||
@@ -1491,6 +1559,7 @@ def update_mail_server_profile(
|
||||
slug: str | None = None,
|
||||
description: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
inherit_to_lower_scopes: bool | None = None,
|
||||
smtp: SmtpConfig | None = None,
|
||||
imap: ImapConfig | None = None,
|
||||
clear_imap: bool = False,
|
||||
@@ -1538,6 +1607,8 @@ def update_mail_server_profile(
|
||||
profile.description = description
|
||||
if is_active is not None:
|
||||
profile.is_active = is_active
|
||||
if inherit_to_lower_scopes is not None:
|
||||
profile.inherit_to_lower_scopes = bool(inherit_to_lower_scopes)
|
||||
|
||||
next_smtp, next_imap = _next_profile_transport_state(profile, smtp=smtp, imap=imap, clear_imap=clear_imap)
|
||||
_assert_profile_transport_allowed(session, tenant_id=tenant_id, profile=profile, smtp=next_smtp, imap=next_imap)
|
||||
@@ -1883,6 +1954,9 @@ def profile_response_payload(profile: MailServerProfile) -> dict[str, Any]:
|
||||
"slug": profile.slug,
|
||||
"description": profile.description,
|
||||
"is_active": profile.is_active,
|
||||
"inherit_to_lower_scopes": bool(
|
||||
getattr(profile, "inherit_to_lower_scopes", True)
|
||||
),
|
||||
"smtp": _server_config_payload(profile.smtp_config),
|
||||
"imap": _server_config_payload(profile.imap_config) if profile.imap_config else None,
|
||||
"credentials": {
|
||||
|
||||
@@ -27,6 +27,8 @@ from govoplan_mail.backend.db import models as mail_models # noqa: F401 - popul
|
||||
|
||||
|
||||
_mail_table_retirement_provider = drop_table_retirement_provider(
|
||||
mail_models.MailServerCredentialBinding,
|
||||
mail_models.MailServerEndpoint,
|
||||
mail_models.MailServerProfile,
|
||||
mail_models.MailProfilePolicy,
|
||||
mail_models.MailMailboxFolderIndex,
|
||||
@@ -194,6 +196,8 @@ manifest = ModuleManifest(
|
||||
),
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
mail_models.MailServerCredentialBinding,
|
||||
mail_models.MailServerEndpoint,
|
||||
mail_models.MailServerProfile,
|
||||
mail_models.MailProfilePolicy,
|
||||
mail_models.MailMailboxFolderIndex,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""split mail envelopes into servers and reusable credential bindings
|
||||
|
||||
Revision ID: 7192a3bcdef0
|
||||
Revises: 608192abcdef
|
||||
Create Date: 2026-07-23 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
|
||||
hierarchy = import_module(
|
||||
"govoplan_mail.backend.migrations.versions.7192a3bcdef0_mail_server_hierarchy"
|
||||
)
|
||||
|
||||
|
||||
revision = hierarchy.revision
|
||||
down_revision = hierarchy.down_revision
|
||||
branch_labels = hierarchy.branch_labels
|
||||
depends_on = hierarchy.depends_on
|
||||
upgrade = hierarchy.upgrade
|
||||
downgrade = hierarchy.downgrade
|
||||
@@ -0,0 +1,265 @@
|
||||
"""split mail envelopes into servers and reusable credential bindings
|
||||
|
||||
Revision ID: 7192a3bcdef0
|
||||
Revises: 608192abcdef
|
||||
Create Date: 2026-07-23 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "7192a3bcdef0"
|
||||
down_revision = "608192abcdef"
|
||||
branch_labels = None
|
||||
depends_on = "c91f0a72be34"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
tables = set(inspector.get_table_names())
|
||||
if "mail_server_profiles" not in tables:
|
||||
return
|
||||
|
||||
profile_columns = {column["name"] for column in inspector.get_columns("mail_server_profiles")}
|
||||
if "inherit_to_lower_scopes" not in profile_columns:
|
||||
with op.batch_alter_table("mail_server_profiles") as batch:
|
||||
batch.add_column(
|
||||
sa.Column(
|
||||
"inherit_to_lower_scopes",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.true(),
|
||||
)
|
||||
)
|
||||
|
||||
if "mail_server_endpoints" not in tables:
|
||||
op.create_table(
|
||||
"mail_server_endpoints",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("profile_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("protocol", sa.String(length=20), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("config", sa.JSON(), nullable=False),
|
||||
sa.Column("scope_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("scope_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("inherit_to_lower_scopes", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_default", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("transport_revision", sa.String(length=36), nullable=False),
|
||||
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("updated_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["profile_id"],
|
||||
["mail_server_profiles.id"],
|
||||
name=op.f("fk_mail_server_endpoints_profile_id_mail_server_profiles"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["core_scopes.id"],
|
||||
name=op.f("fk_mail_server_endpoints_tenant_id_core_scopes"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["created_by_user_id"],
|
||||
["access_users.id"],
|
||||
name=op.f("fk_mail_server_endpoints_created_by_user_id_access_users"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["updated_by_user_id"],
|
||||
["access_users.id"],
|
||||
name=op.f("fk_mail_server_endpoints_updated_by_user_id_access_users"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_mail_server_endpoints")),
|
||||
sa.UniqueConstraint(
|
||||
"profile_id",
|
||||
"protocol",
|
||||
"name",
|
||||
name="uq_mail_server_endpoints_profile_protocol_name",
|
||||
),
|
||||
)
|
||||
_create_endpoint_indexes()
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if "mail_server_credential_bindings" not in inspector.get_table_names():
|
||||
op.create_table(
|
||||
"mail_server_credential_bindings",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("server_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("credential_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("is_default", sa.Boolean(), nullable=False),
|
||||
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["server_id"],
|
||||
["mail_server_endpoints.id"],
|
||||
name=op.f("fk_mail_server_credential_bindings_server_id_mail_server_endpoints"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["credential_id"],
|
||||
["core_credential_envelopes.id"],
|
||||
name=op.f("fk_mail_server_credential_bindings_credential_id_core_credential_envelopes"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["created_by_user_id"],
|
||||
["access_users.id"],
|
||||
name=op.f("fk_mail_server_credential_bindings_created_by_user_id_access_users"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_mail_server_credential_bindings")),
|
||||
sa.UniqueConstraint(
|
||||
"server_id",
|
||||
"credential_id",
|
||||
name="uq_mail_server_credential_bindings_server_credential",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_mail_server_credential_bindings_default",
|
||||
"mail_server_credential_bindings",
|
||||
["server_id", "is_default"],
|
||||
unique=False,
|
||||
)
|
||||
for column in ("server_id", "credential_id", "is_default", "created_by_user_id"):
|
||||
op.create_index(
|
||||
op.f(f"ix_mail_server_credential_bindings_{column}"),
|
||||
"mail_server_credential_bindings",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
_seed_legacy_endpoints(bind)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
tables = set(inspector.get_table_names())
|
||||
if "mail_server_credential_bindings" in tables:
|
||||
op.drop_table("mail_server_credential_bindings")
|
||||
if "mail_server_endpoints" in tables:
|
||||
op.drop_table("mail_server_endpoints")
|
||||
if "mail_server_profiles" in tables:
|
||||
columns = {column["name"] for column in inspector.get_columns("mail_server_profiles")}
|
||||
if "inherit_to_lower_scopes" in columns:
|
||||
with op.batch_alter_table("mail_server_profiles") as batch:
|
||||
batch.drop_column("inherit_to_lower_scopes")
|
||||
|
||||
|
||||
def _create_endpoint_indexes() -> None:
|
||||
op.create_index(
|
||||
"ix_mail_server_endpoints_profile_protocol",
|
||||
"mail_server_endpoints",
|
||||
["profile_id", "protocol", "is_active"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_mail_server_endpoints_scope",
|
||||
"mail_server_endpoints",
|
||||
["tenant_id", "scope_type", "scope_id"],
|
||||
unique=False,
|
||||
)
|
||||
for column in (
|
||||
"profile_id",
|
||||
"tenant_id",
|
||||
"protocol",
|
||||
"scope_type",
|
||||
"scope_id",
|
||||
"is_default",
|
||||
"is_active",
|
||||
"created_by_user_id",
|
||||
"updated_by_user_id",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_mail_server_endpoints_{column}"),
|
||||
"mail_server_endpoints",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def _seed_legacy_endpoints(bind) -> None:
|
||||
existing = {
|
||||
(row.profile_id, row.protocol)
|
||||
for row in bind.execute(
|
||||
sa.text("SELECT profile_id, protocol FROM mail_server_endpoints WHERE is_default = :is_default"),
|
||||
{"is_default": True},
|
||||
)
|
||||
}
|
||||
rows = bind.execute(
|
||||
sa.text(
|
||||
"SELECT id, tenant_id, scope_type, scope_id, smtp_config, imap_config, "
|
||||
"smtp_transport_revision, imap_transport_revision, created_by_user_id, updated_by_user_id "
|
||||
"FROM mail_server_profiles"
|
||||
)
|
||||
).mappings()
|
||||
now = datetime.now(timezone.utc)
|
||||
table = sa.table(
|
||||
"mail_server_endpoints",
|
||||
sa.column("id", sa.String),
|
||||
sa.column("profile_id", sa.String),
|
||||
sa.column("tenant_id", sa.String),
|
||||
sa.column("protocol", sa.String),
|
||||
sa.column("name", sa.String),
|
||||
sa.column("config", sa.JSON),
|
||||
sa.column("scope_type", sa.String),
|
||||
sa.column("scope_id", sa.String),
|
||||
sa.column("inherit_to_lower_scopes", sa.Boolean),
|
||||
sa.column("is_default", sa.Boolean),
|
||||
sa.column("is_active", sa.Boolean),
|
||||
sa.column("transport_revision", sa.String),
|
||||
sa.column("created_by_user_id", sa.String),
|
||||
sa.column("updated_by_user_id", sa.String),
|
||||
sa.column("created_at", sa.DateTime(timezone=True)),
|
||||
sa.column("updated_at", sa.DateTime(timezone=True)),
|
||||
)
|
||||
for row in rows:
|
||||
for protocol, config_key, revision_key in (
|
||||
("smtp", "smtp_config", "smtp_transport_revision"),
|
||||
("imap", "imap_config", "imap_transport_revision"),
|
||||
):
|
||||
config = _json_object(row[config_key])
|
||||
if not config or (row["id"], protocol) in existing:
|
||||
continue
|
||||
bind.execute(
|
||||
table.insert().values(
|
||||
id=str(uuid.uuid4()),
|
||||
profile_id=row["id"],
|
||||
tenant_id=row["tenant_id"],
|
||||
protocol=protocol,
|
||||
name=protocol.upper(),
|
||||
config=config,
|
||||
scope_type=row["scope_type"] or "tenant",
|
||||
scope_id=row["scope_id"],
|
||||
inherit_to_lower_scopes=True,
|
||||
is_default=True,
|
||||
is_active=True,
|
||||
transport_revision=row[revision_key] or str(uuid.uuid4()),
|
||||
created_by_user_id=row["created_by_user_id"],
|
||||
updated_by_user_id=row["updated_by_user_id"],
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _json_object(value):
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str) and value.strip():
|
||||
parsed = json.loads(value)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
return {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator
|
||||
|
||||
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||
from govoplan_mail.backend.config import (
|
||||
@@ -103,6 +103,7 @@ class MailServerProfileCreateRequest(BaseModel):
|
||||
slug: str | None = Field(default=None, max_length=100)
|
||||
description: str | None = None
|
||||
is_active: bool = True
|
||||
inherit_to_lower_scopes: bool = True
|
||||
scope_type: MailProfileScope = "tenant"
|
||||
scope_id: str | None = None
|
||||
smtp: SmtpServerConfig
|
||||
@@ -137,6 +138,7 @@ class MailServerProfileUpdateRequest(BaseModel):
|
||||
slug: str | None = Field(default=None, max_length=100)
|
||||
description: str | None = None
|
||||
is_active: bool | None = None
|
||||
inherit_to_lower_scopes: bool | None = None
|
||||
smtp: SmtpServerConfig | None = None
|
||||
imap: ImapServerConfig | None = None
|
||||
credentials: MailServerProfileCredentialsPayload = Field(default_factory=MailServerProfileCredentialsPayload)
|
||||
@@ -167,6 +169,127 @@ class MailServerProfileUpdateRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class MailCredentialEnvelopeResponse(BaseModel):
|
||||
id: str
|
||||
binding_id: str | None = None
|
||||
server_id: str | None = None
|
||||
tenant_id: str | None = None
|
||||
scope_type: MailProfileScope
|
||||
scope_id: str | None = None
|
||||
name: str
|
||||
description: str | None = None
|
||||
credential_kind: str
|
||||
public_data: dict[str, Any] = Field(default_factory=dict)
|
||||
secret_keys: list[str] = Field(default_factory=list)
|
||||
secret_configured: bool = False
|
||||
allowed_modules: list[str] = Field(default_factory=list)
|
||||
allowed_server_refs: list[str] = Field(default_factory=list)
|
||||
inherit_to_lower_scopes: bool = False
|
||||
is_default: bool = False
|
||||
is_active: bool = True
|
||||
revision: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
deleted_at: datetime | None = None
|
||||
|
||||
|
||||
class MailServerEndpointResponse(BaseModel):
|
||||
id: str
|
||||
profile_id: str
|
||||
tenant_id: str | None = None
|
||||
protocol: Literal["smtp", "imap"]
|
||||
name: str
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
scope_type: MailProfileScope
|
||||
scope_id: str | None = None
|
||||
inherit_to_lower_scopes: bool = True
|
||||
is_default: bool = False
|
||||
is_active: bool = True
|
||||
transport_revision: str
|
||||
credentials: list[MailCredentialEnvelopeResponse] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class MailServerEndpointCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
protocol: Literal["smtp", "imap"]
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
inherit_to_lower_scopes: bool | None = None
|
||||
is_default: bool = False
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class MailServerEndpointUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
config: dict[str, Any] | None = None
|
||||
inherit_to_lower_scopes: bool | None = None
|
||||
is_default: bool | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class MailCredentialCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
credential_kind: str = "username_password"
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
public_data: dict[str, Any] = Field(default_factory=dict)
|
||||
secret_data: dict[str, Any] = Field(default_factory=dict)
|
||||
inherit_to_lower_scopes: bool | None = None
|
||||
allowed_modules: list[str] = Field(default_factory=lambda: ["mail"])
|
||||
allowed_server_refs: list[str] = Field(default_factory=list)
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class MailCampaignCredentialCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
username: str = Field(min_length=1, max_length=320)
|
||||
password: SecretStr
|
||||
server_ids: list[str] = Field(min_length=1)
|
||||
|
||||
|
||||
class MailCredentialBindRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
credential_id: str = Field(min_length=1)
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class MailCredentialUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
description: str | None = None
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
public_data: dict[str, Any] | None = None
|
||||
secret_data: dict[str, Any] | None = None
|
||||
inherit_to_lower_scopes: bool | None = None
|
||||
allowed_modules: list[str] | None = None
|
||||
allowed_server_refs: list[str] | None = None
|
||||
is_default: bool | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class MailCredentialUnlinkRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
retire_if_unused: bool = False
|
||||
|
||||
|
||||
class MailCredentialListResponse(BaseModel):
|
||||
credentials: list[MailCredentialEnvelopeResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MailServerProfileResponse(BaseModel):
|
||||
id: str
|
||||
tenant_id: str | None = None
|
||||
@@ -176,11 +299,13 @@ class MailServerProfileResponse(BaseModel):
|
||||
slug: str
|
||||
description: str | None = None
|
||||
is_active: bool
|
||||
inherit_to_lower_scopes: bool = True
|
||||
smtp: dict[str, Any]
|
||||
imap: dict[str, Any] | None = None
|
||||
credentials: dict[str, Any] = Field(default_factory=dict)
|
||||
smtp_password_configured: bool = False
|
||||
imap_password_configured: bool = False
|
||||
servers: list[MailServerEndpointResponse] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
1513
src/govoplan_mail/backend/server_hierarchy.py
Normal file
1513
src/govoplan_mail/backend/server_hierarchy.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ from govoplan_mail.backend.mail_profiles import (
|
||||
_assert_campaign_inherits_profile_credentials,
|
||||
_campaign_mail_profile_reference_id,
|
||||
_apply_profile_transport_update,
|
||||
campaign_mail_selection_from_json,
|
||||
campaign_profile_transport_revisions,
|
||||
create_mail_server_profile,
|
||||
_merge_policy,
|
||||
@@ -496,14 +497,26 @@ class MailProfileTransportHelperTests(unittest.TestCase):
|
||||
|
||||
|
||||
class MailProfilePolicyHelperTests(unittest.TestCase):
|
||||
def test_campaign_contract_accepts_only_mail_profile_reference(self):
|
||||
def test_campaign_contract_accepts_only_mail_owned_references(self):
|
||||
self.assertEqual(
|
||||
_campaign_mail_profile_reference_id({"mail_profile_id": " profile-1 "}),
|
||||
"profile-1",
|
||||
)
|
||||
for legacy in ("smtp", "imap", "credentials", "inherit_smtp_credentials"):
|
||||
with self.subTest(legacy=legacy), self.assertRaisesRegex(MailProfileError, "select a Mail profile"):
|
||||
with self.subTest(legacy=legacy), self.assertRaisesRegex(MailProfileError, "select Mail resources"):
|
||||
_campaign_mail_profile_reference_id({"mail_profile_id": "profile-1", legacy: {}})
|
||||
self.assertEqual(
|
||||
campaign_mail_selection_from_json(
|
||||
{
|
||||
"server": {
|
||||
"mail_profile_id": "profile-1",
|
||||
"smtp_server_id": "smtp-1",
|
||||
"smtp_credential_id": "credential-1",
|
||||
}
|
||||
}
|
||||
)["smtp_credential_id"],
|
||||
"credential-1",
|
||||
)
|
||||
|
||||
def test_campaign_delivery_fails_when_policy_requires_local_credentials(self):
|
||||
profile = SimpleNamespace(imap_config=None)
|
||||
@@ -511,7 +524,7 @@ class MailProfilePolicyHelperTests(unittest.TestCase):
|
||||
smtp_credentials=EffectiveCredentialPolicy(inherit=False),
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(MailProfileError, "Store the credentials on a Mail profile"):
|
||||
with self.assertRaisesRegex(MailProfileError, "explicit credential selection"):
|
||||
_assert_campaign_inherits_profile_credentials(profile, policy)
|
||||
|
||||
def test_merge_policy_respects_locked_lower_level_limits(self):
|
||||
|
||||
@@ -409,6 +409,7 @@ class ProfileActorAuthorizationTests(unittest.TestCase):
|
||||
"govoplan_mail.backend.router._profile_response",
|
||||
return_value=profile,
|
||||
),
|
||||
patch("govoplan_mail.backend.router.sync_default_profile_server"),
|
||||
):
|
||||
result = router.update_profile(
|
||||
profile.id,
|
||||
@@ -446,6 +447,7 @@ class ProfileActorAuthorizationTests(unittest.TestCase):
|
||||
"govoplan_mail.backend.router._profile_response",
|
||||
return_value=profile,
|
||||
),
|
||||
patch("govoplan_mail.backend.router.sync_default_profile_server"),
|
||||
):
|
||||
router.update_profile(
|
||||
profile.id,
|
||||
|
||||
@@ -166,6 +166,7 @@ class MailProfileDeletionRouteTests(unittest.TestCase):
|
||||
with (
|
||||
patch("govoplan_mail.backend.router._require_profile_write_scope"),
|
||||
patch("govoplan_mail.backend.router.create_mail_server_profile", return_value=system_profile),
|
||||
patch("govoplan_mail.backend.router.initialize_profile_hierarchy"),
|
||||
patch("govoplan_mail.backend.router._record_mail_change") as create_change,
|
||||
patch("govoplan_mail.backend.router._profile_response", return_value=system_profile),
|
||||
):
|
||||
|
||||
@@ -4,10 +4,12 @@ import type {
|
||||
MailConnectionTestResponse,
|
||||
MailImapFolderListResponse,
|
||||
MailImapTestPayload,
|
||||
MailCredentialEnvelope,
|
||||
MailProfilePolicy,
|
||||
MailProfilePolicyResponse,
|
||||
MailProfileScope,
|
||||
MailSecurity,
|
||||
MailServerEndpoint,
|
||||
MailServerProfile,
|
||||
MailServerProfilePayload,
|
||||
MailSmtpTestPayload,
|
||||
@@ -18,6 +20,7 @@ import { apiFetch, apiGetList, apiPath, apiPost, apiPostJson } from "./client";
|
||||
export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "@govoplan/core-webui";
|
||||
export type {
|
||||
MailConnectionTestResponse,
|
||||
MailCredentialEnvelope,
|
||||
MailCredentialPolicy,
|
||||
MailImapFolderListResponse,
|
||||
MailImapFolderResponse,
|
||||
@@ -30,6 +33,7 @@ export type {
|
||||
MailProfilePolicyResponse,
|
||||
MailProfileScope,
|
||||
MailSecurity,
|
||||
MailServerEndpoint,
|
||||
MailServerProfile,
|
||||
MailServerProfileCredentialsPayload,
|
||||
MailServerProfileListResponse,
|
||||
@@ -176,6 +180,33 @@ export async function createMailServerProfile(settings: ApiSettings, payload: Ma
|
||||
|
||||
export type MailServerProfileUpdatePayload = Partial<MailServerProfilePayload> & { clear_imap?: boolean };
|
||||
|
||||
export type MailServerEndpointPayload = {
|
||||
protocol: "smtp" | "imap";
|
||||
name: string;
|
||||
config: Record<string, unknown>;
|
||||
inherit_to_lower_scopes?: boolean | null;
|
||||
is_default?: boolean;
|
||||
is_active?: boolean;
|
||||
};
|
||||
|
||||
export type MailCredentialCreatePayload = {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
credential_kind?: string;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
public_data?: Record<string, unknown>;
|
||||
secret_data?: Record<string, unknown>;
|
||||
inherit_to_lower_scopes?: boolean | null;
|
||||
allowed_modules?: string[];
|
||||
allowed_server_refs?: string[];
|
||||
is_default?: boolean;
|
||||
};
|
||||
|
||||
export type MailCredentialUpdatePayload = Partial<MailCredentialCreatePayload> & {
|
||||
is_active?: 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",
|
||||
@@ -187,6 +218,118 @@ export async function deactivateMailServerProfile(settings: ApiSettings, profile
|
||||
return apiFetch<MailServerProfile>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function createMailServerEndpoint(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
payload: MailServerEndpointPayload
|
||||
): Promise<MailServerEndpoint> {
|
||||
return apiFetch<MailServerEndpoint>(
|
||||
settings,
|
||||
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/servers`,
|
||||
{ method: "POST", body: JSON.stringify(payload) }
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateMailServerEndpoint(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
serverId: string,
|
||||
payload: Partial<Omit<MailServerEndpointPayload, "protocol">>
|
||||
): Promise<MailServerEndpoint> {
|
||||
return apiFetch<MailServerEndpoint>(
|
||||
settings,
|
||||
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/servers/${encodeURIComponent(serverId)}`,
|
||||
{ method: "PATCH", body: JSON.stringify(payload) }
|
||||
);
|
||||
}
|
||||
|
||||
export async function deactivateMailServerEndpoint(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
serverId: string
|
||||
): Promise<MailServerEndpoint> {
|
||||
return apiFetch<MailServerEndpoint>(
|
||||
settings,
|
||||
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/servers/${encodeURIComponent(serverId)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
}
|
||||
|
||||
export async function listAvailableMailCredentials(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
serverId: string,
|
||||
includeInactive = false
|
||||
): Promise<MailCredentialEnvelope[]> {
|
||||
return apiGetList<MailCredentialEnvelope, "credentials">(
|
||||
settings,
|
||||
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/servers/${encodeURIComponent(serverId)}/available-credentials`,
|
||||
"credentials",
|
||||
{ include_inactive: includeInactive ? true : undefined }
|
||||
);
|
||||
}
|
||||
|
||||
export async function createMailServerCredential(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
serverId: string,
|
||||
payload: MailCredentialCreatePayload
|
||||
): Promise<MailCredentialEnvelope> {
|
||||
return apiFetch<MailCredentialEnvelope>(
|
||||
settings,
|
||||
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/servers/${encodeURIComponent(serverId)}/credentials`,
|
||||
{ method: "POST", body: JSON.stringify(payload) }
|
||||
);
|
||||
}
|
||||
|
||||
export async function bindMailServerCredential(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
serverId: string,
|
||||
credentialId: string,
|
||||
isDefault = false
|
||||
): Promise<MailCredentialEnvelope> {
|
||||
return apiFetch<MailCredentialEnvelope>(
|
||||
settings,
|
||||
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/servers/${encodeURIComponent(serverId)}/credential-bindings`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ credential_id: credentialId, is_default: isDefault })
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateMailServerCredential(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
serverId: string,
|
||||
credentialId: string,
|
||||
payload: MailCredentialUpdatePayload
|
||||
): Promise<MailCredentialEnvelope> {
|
||||
return apiFetch<MailCredentialEnvelope>(
|
||||
settings,
|
||||
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/servers/${encodeURIComponent(serverId)}/credentials/${encodeURIComponent(credentialId)}`,
|
||||
{ method: "PATCH", body: JSON.stringify(payload) }
|
||||
);
|
||||
}
|
||||
|
||||
export async function unlinkMailServerCredential(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
serverId: string,
|
||||
credentialId: string,
|
||||
retireIfUnused = false
|
||||
): Promise<void> {
|
||||
await apiFetch<void>(
|
||||
settings,
|
||||
apiPath(
|
||||
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/servers/${encodeURIComponent(serverId)}/credentials/${encodeURIComponent(credentialId)}`,
|
||||
{ retire_if_unused: retireIfUnused ? true : undefined }
|
||||
),
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
}
|
||||
|
||||
export async function getMailProfilePolicy(
|
||||
settings: ApiSettings,
|
||||
scopeType: MailProfileScope,
|
||||
@@ -211,16 +354,55 @@ export async function updateMailProfilePolicy(
|
||||
});
|
||||
}
|
||||
|
||||
export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
|
||||
return apiPost<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-smtp`);
|
||||
export async function testMailProfileSmtp(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
serverId?: string | null,
|
||||
credentialId?: string | null,
|
||||
campaignId?: string | null
|
||||
): Promise<MailConnectionTestResponse> {
|
||||
return apiPost<MailConnectionTestResponse>(
|
||||
settings,
|
||||
apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-smtp`, {
|
||||
server_id: serverId,
|
||||
credential_id: credentialId,
|
||||
campaign_id: campaignId
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function testMailProfileImap(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
|
||||
return apiPost<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-imap`);
|
||||
export async function testMailProfileImap(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
serverId?: string | null,
|
||||
credentialId?: string | null,
|
||||
campaignId?: string | null
|
||||
): Promise<MailConnectionTestResponse> {
|
||||
return apiPost<MailConnectionTestResponse>(
|
||||
settings,
|
||||
apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-imap`, {
|
||||
server_id: serverId,
|
||||
credential_id: credentialId,
|
||||
campaign_id: campaignId
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function listMailProfileImapFolders(settings: ApiSettings, profileId: string): Promise<MailImapFolderListResponse> {
|
||||
return apiPost<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`);
|
||||
export async function listMailProfileImapFolders(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
serverId?: string | null,
|
||||
credentialId?: string | null,
|
||||
campaignId?: string | null
|
||||
): Promise<MailImapFolderListResponse> {
|
||||
return apiPost<MailImapFolderListResponse>(
|
||||
settings,
|
||||
apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`, {
|
||||
server_id: serverId,
|
||||
credential_id: credentialId,
|
||||
campaign_id: campaignId
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function listMailboxFolders(settings: ApiSettings, profileId: string, includeStatus = false, refresh = false): Promise<MailImapFolderListResponse> {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,8 @@ export type MailProfilePanelMode = "all" | "server" | "credentials";
|
||||
export type MailProfileEditTarget =
|
||||
| {kind: "create";}
|
||||
| {kind: "profile";}
|
||||
| {kind: "server";protocol: MailProfileProtocol;}
|
||||
| {kind: "credentials";protocol: MailProfileProtocol;};
|
||||
| {kind: "server";protocol: MailProfileProtocol;serverId?: string;}
|
||||
| {kind: "credentials";protocol: MailProfileProtocol;serverId?: string;credentialId?: string;};
|
||||
|
||||
export type MailProfileTransportLike = {
|
||||
host?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user