Add hierarchical mail servers and credentials

This commit is contained in:
2026-07-28 19:32:56 +02:00
parent 2813c671c0
commit 3735027b3d
15 changed files with 4105 additions and 246 deletions

View File

@@ -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,
)
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 = profile.smtp_config or {}
imap = profile.imap_config or {}
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,25 +283,64 @@ 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:
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
@@ -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,30 +391,82 @@ 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
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)
if revisions["smtp"] != expected_smtp_transport_revision:
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:
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
@@ -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:

View File

@@ -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__ = (

View File

@@ -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": {

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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):

View File

@@ -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,

View File

@@ -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),
):

View File

@@ -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

View File

@@ -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;