1309 lines
45 KiB
Python
1309 lines
45 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass, replace
|
|
import re
|
|
from typing import Any
|
|
|
|
from sqlalchemy import func, or_, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_core.core.configuration_packages import (
|
|
ConfigurationApplyResult,
|
|
ConfigurationDiagnostic,
|
|
ConfigurationExportResult,
|
|
ConfigurationExportSelection,
|
|
ConfigurationPackageFragment,
|
|
ConfigurationPlanItem,
|
|
ConfigurationPreflightContext,
|
|
ConfigurationPreflightResult,
|
|
ConfigurationProvider,
|
|
ConfigurationProviderDescription,
|
|
ConfigurationRequiredData,
|
|
)
|
|
from govoplan_core.core.infrastructure_capabilities import (
|
|
InfrastructureCapability,
|
|
InfrastructureCapabilityReceipt,
|
|
InfrastructureDependency,
|
|
InfrastructureDependencyProvider,
|
|
)
|
|
from govoplan_core.security.credential_envelopes import (
|
|
CredentialAccessContext,
|
|
CredentialEnvelope,
|
|
credential_visible_to_context,
|
|
)
|
|
from govoplan_core.db.session import get_database
|
|
from govoplan_mail.backend.config import SmtpConfig, SmtpServerConfig
|
|
from govoplan_mail.backend.db.models import (
|
|
MailServerCredentialBinding,
|
|
MailServerEndpoint,
|
|
MailServerProfile,
|
|
)
|
|
from govoplan_mail.backend.mail_profiles import (
|
|
create_mail_server_profile,
|
|
slugify_profile_name,
|
|
update_mail_server_profile,
|
|
)
|
|
from govoplan_mail.backend.server_hierarchy import (
|
|
bind_mail_credential,
|
|
initialize_profile_hierarchy,
|
|
mail_server_ref,
|
|
sync_default_profile_server,
|
|
)
|
|
|
|
|
|
MAIL_CONFIGURATION_CAPABILITY = "mail.configuration"
|
|
MAIL_INFRASTRUCTURE_DEPENDENCY_CAPABILITY = (
|
|
"infrastructure.dependency_inventory.mail"
|
|
)
|
|
SMTP_PROFILE_FRAGMENT = "smtp_profile"
|
|
_SYSTEM_CONFIGURATION_SCOPES = frozenset(
|
|
{"system:settings:write", "system:governance:write"}
|
|
)
|
|
_PAYLOAD_KEYS = frozenset(
|
|
{
|
|
"capability_id",
|
|
"profile",
|
|
"smtp",
|
|
"credential_envelope_id",
|
|
"credential_is_default",
|
|
"input_keys",
|
|
"on_conflict",
|
|
}
|
|
)
|
|
_PROFILE_KEYS = frozenset(
|
|
{
|
|
"name",
|
|
"slug",
|
|
"description",
|
|
"scope_type",
|
|
"inherit_to_lower_scopes",
|
|
}
|
|
)
|
|
_SMTP_KEYS = frozenset({"host", "port", "security", "timeout_seconds"})
|
|
_INPUT_KEY_FIELDS = frozenset({"host", "port", "security", "credential_envelope_id"})
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _DesiredSmtpProfile:
|
|
fragment_ref: str
|
|
tenant_id: str | None
|
|
scope_type: str
|
|
scope_id: str | None
|
|
name: str
|
|
slug: str
|
|
description: str | None
|
|
inherit_to_lower_scopes: bool
|
|
smtp: SmtpConfig
|
|
credential_envelope_id: str | None
|
|
credential_is_default: bool
|
|
on_conflict: str
|
|
capability_source: str
|
|
installation_id: str
|
|
operator_user_id: str | None
|
|
|
|
@property
|
|
def smtp_payload(self) -> dict[str, object]:
|
|
payload = self.smtp.model_dump(mode="json")
|
|
payload.pop("username", None)
|
|
payload.pop("password", None)
|
|
return payload
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _ParsedMailFragment:
|
|
payload: Mapping[str, Any]
|
|
profile: Mapping[str, Any]
|
|
smtp: Mapping[str, Any]
|
|
input_keys: Mapping[str, Any]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _ResolvedSmtpInputs:
|
|
smtp: SmtpConfig
|
|
credential_envelope_id: str | None
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _ProfileTarget:
|
|
tenant_id: str | None
|
|
scope_type: str
|
|
scope_id: str | None
|
|
name: str
|
|
slug: str
|
|
description: str | None
|
|
inherit_to_lower_scopes: bool
|
|
credential_is_default: bool
|
|
on_conflict: str
|
|
|
|
|
|
class SqlMailConfigurationProvider(
|
|
ConfigurationProvider,
|
|
InfrastructureDependencyProvider,
|
|
):
|
|
module_id = "mail"
|
|
capability_ids = ("mail.smtp",)
|
|
|
|
def describe(self) -> ConfigurationProviderDescription:
|
|
return ConfigurationProviderDescription(
|
|
module_id=self.module_id,
|
|
fragment_types=(SMTP_PROFILE_FRAGMENT,),
|
|
schema_refs={
|
|
SMTP_PROFILE_FRAGMENT: "govoplan/mail/configuration/smtp-profile.v1"
|
|
},
|
|
exported_scopes=("system", "tenant"),
|
|
)
|
|
|
|
def preflight(
|
|
self,
|
|
fragment: ConfigurationPackageFragment,
|
|
context: ConfigurationPreflightContext,
|
|
) -> ConfigurationPreflightResult:
|
|
if fragment.fragment_type != SMTP_PROFILE_FRAGMENT:
|
|
return ConfigurationPreflightResult(
|
|
diagnostics=(_unsupported(fragment),),
|
|
plan=(_blocked_plan(fragment, "Fragment type is unsupported."),),
|
|
)
|
|
with get_database().session() as session:
|
|
return _preflight_smtp_profile(session, fragment, context)
|
|
|
|
def apply(
|
|
self,
|
|
fragment: ConfigurationPackageFragment,
|
|
supplied_data: Mapping[str, Any],
|
|
context: ConfigurationPreflightContext,
|
|
) -> ConfigurationApplyResult:
|
|
if fragment.fragment_type != SMTP_PROFILE_FRAGMENT:
|
|
return ConfigurationApplyResult(diagnostics=(_unsupported(fragment),))
|
|
apply_context = replace(context, supplied_data=supplied_data, dry_run=False)
|
|
with get_database().session() as session:
|
|
preflight = _preflight_smtp_profile(session, fragment, apply_context)
|
|
blockers = tuple(
|
|
item for item in preflight.diagnostics if item.severity == "blocker"
|
|
)
|
|
if blockers:
|
|
return ConfigurationApplyResult(diagnostics=blockers)
|
|
desired, diagnostics, _required_data = _desired_smtp_profile(
|
|
fragment,
|
|
apply_context,
|
|
)
|
|
if desired is None:
|
|
return ConfigurationApplyResult(diagnostics=tuple(diagnostics))
|
|
result = _apply_smtp_profile(session, desired)
|
|
if not any(item.severity == "blocker" for item in result.diagnostics):
|
|
session.commit()
|
|
return result
|
|
|
|
def export(
|
|
self,
|
|
selection: ConfigurationExportSelection,
|
|
context: ConfigurationPreflightContext,
|
|
) -> ConfigurationExportResult:
|
|
del context
|
|
with get_database().session() as session:
|
|
return _export_smtp_profiles(session, selection)
|
|
|
|
def health(
|
|
self,
|
|
import_result: ConfigurationApplyResult,
|
|
context: ConfigurationPreflightContext,
|
|
) -> tuple[ConfigurationDiagnostic, ...]:
|
|
del context
|
|
return tuple(
|
|
item for item in import_result.diagnostics if item.severity == "blocker"
|
|
)
|
|
|
|
def infrastructure_dependencies(self) -> tuple[InfrastructureDependency, ...]:
|
|
with get_database().session() as session:
|
|
return _smtp_infrastructure_dependencies(session)
|
|
|
|
|
|
def _smtp_infrastructure_dependencies(
|
|
session: Session,
|
|
) -> tuple[InfrastructureDependency, ...]:
|
|
endpoints = tuple(
|
|
session.execute(
|
|
select(MailServerEndpoint)
|
|
.where(MailServerEndpoint.protocol == "smtp")
|
|
.order_by(MailServerEndpoint.id)
|
|
).scalars()
|
|
)
|
|
binding_counts = {
|
|
str(server_id): int(count)
|
|
for server_id, count in session.execute(
|
|
select(
|
|
MailServerCredentialBinding.server_id,
|
|
func.count(MailServerCredentialBinding.id),
|
|
).group_by(MailServerCredentialBinding.server_id)
|
|
)
|
|
}
|
|
dependencies: list[InfrastructureDependency] = []
|
|
endpoint_profile_ids: set[str] = set()
|
|
for endpoint in endpoints:
|
|
endpoint_profile_ids.add(endpoint.profile_id)
|
|
dependencies.append(
|
|
InfrastructureDependency(
|
|
capability_id="mail.smtp",
|
|
module_id="mail",
|
|
dependency_type="smtp_endpoint",
|
|
dependency_ref=mail_server_ref(endpoint.id) or f"mail:{endpoint.id}",
|
|
state="active" if endpoint.is_active else "inactive",
|
|
scope=str(endpoint.scope_type or "tenant"),
|
|
summary=(
|
|
"A persisted Mail SMTP endpoint is bound to the deployment relay."
|
|
),
|
|
metrics={
|
|
"credential_binding_count": binding_counts.get(endpoint.id, 0),
|
|
"default_endpoint": int(bool(endpoint.is_default)),
|
|
},
|
|
required_action=(
|
|
"Rebind, migrate, or explicitly retire this SMTP endpoint and its credential-envelope references before changing the relay capability."
|
|
),
|
|
)
|
|
)
|
|
|
|
legacy_profiles = tuple(
|
|
session.execute(
|
|
select(MailServerProfile)
|
|
.where(MailServerProfile.smtp_config.is_not(None))
|
|
.order_by(MailServerProfile.id)
|
|
).scalars()
|
|
)
|
|
for profile in legacy_profiles:
|
|
if profile.id in endpoint_profile_ids or not dict(profile.smtp_config or {}):
|
|
continue
|
|
dependencies.append(
|
|
InfrastructureDependency(
|
|
capability_id="mail.smtp",
|
|
module_id="mail",
|
|
dependency_type="legacy_smtp_profile",
|
|
dependency_ref=f"mail-profile:{profile.id}",
|
|
state="active" if profile.is_active else "inactive",
|
|
scope=str(profile.scope_type or "tenant"),
|
|
summary=(
|
|
"A persisted legacy Mail profile still contains SMTP transport configuration."
|
|
),
|
|
metrics={"credential_binding_count": 0},
|
|
required_action=(
|
|
"Migrate or explicitly retire this legacy profile before changing the relay capability."
|
|
),
|
|
)
|
|
)
|
|
return tuple(dependencies)
|
|
|
|
|
|
def _preflight_smtp_profile(
|
|
session: Session,
|
|
fragment: ConfigurationPackageFragment,
|
|
context: ConfigurationPreflightContext,
|
|
) -> ConfigurationPreflightResult:
|
|
desired, diagnostics, required_data = _desired_smtp_profile(fragment, context)
|
|
if desired is None:
|
|
return ConfigurationPreflightResult(
|
|
diagnostics=tuple(diagnostics),
|
|
required_data=tuple(required_data),
|
|
plan=(_blocked_plan(fragment, "SMTP profile inputs are incomplete."),),
|
|
)
|
|
profile = _profile_by_identity(session, desired)
|
|
if profile is None:
|
|
credential_diagnostics = _credential_diagnostics(
|
|
session,
|
|
desired,
|
|
server=None,
|
|
)
|
|
diagnostics.extend(credential_diagnostics)
|
|
action = "blocked" if _has_blockers(diagnostics) else "create"
|
|
return ConfigurationPreflightResult(
|
|
diagnostics=tuple(diagnostics),
|
|
required_data=tuple(required_data),
|
|
plan=(
|
|
ConfigurationPlanItem(
|
|
action=action,
|
|
module_id="mail",
|
|
fragment_type=SMTP_PROFILE_FRAGMENT,
|
|
fragment_id=desired.fragment_ref,
|
|
summary=(
|
|
"Create a receipt-bound SMTP profile and optional credential binding."
|
|
if action == "create"
|
|
else "SMTP profile creation is blocked."
|
|
),
|
|
),
|
|
),
|
|
)
|
|
|
|
server = _default_smtp_server(session, profile.id)
|
|
credential_diagnostics = _credential_diagnostics(
|
|
session,
|
|
desired,
|
|
server=server,
|
|
)
|
|
diagnostics.extend(credential_diagnostics)
|
|
changes = _profile_changes(session, profile, server, desired)
|
|
if changes and desired.on_conflict == "preserve":
|
|
diagnostics.append(
|
|
ConfigurationDiagnostic(
|
|
severity="blocker",
|
|
code="mail_configuration_conflict",
|
|
message=(
|
|
f"Mail profile {desired.slug!r} already exists with different "
|
|
f"configuration ({', '.join(changes)})."
|
|
),
|
|
module_id="mail",
|
|
object_ref=desired.fragment_ref,
|
|
resolution=(
|
|
"Keep the existing profile, choose another stable slug, or review "
|
|
"the package with on_conflict set to update."
|
|
),
|
|
)
|
|
)
|
|
if _has_blockers(diagnostics):
|
|
action = "blocked"
|
|
summary = (
|
|
"Existing SMTP profile is preserved because the reviewed change is blocked."
|
|
)
|
|
elif changes:
|
|
action = "update"
|
|
summary = f"Update receipt-bound SMTP profile fields: {', '.join(changes)}."
|
|
else:
|
|
action = "skip"
|
|
summary = "SMTP profile and credential binding already match the receipt."
|
|
return ConfigurationPreflightResult(
|
|
diagnostics=tuple(diagnostics),
|
|
required_data=tuple(required_data),
|
|
plan=(
|
|
ConfigurationPlanItem(
|
|
action=action,
|
|
module_id="mail",
|
|
fragment_type=SMTP_PROFILE_FRAGMENT,
|
|
fragment_id=desired.fragment_ref,
|
|
summary=summary,
|
|
),
|
|
),
|
|
)
|
|
|
|
|
|
def _desired_smtp_profile(
|
|
fragment: ConfigurationPackageFragment,
|
|
context: ConfigurationPreflightContext,
|
|
) -> tuple[
|
|
_DesiredSmtpProfile | None,
|
|
list[ConfigurationDiagnostic],
|
|
list[ConfigurationRequiredData],
|
|
]:
|
|
diagnostics: list[ConfigurationDiagnostic] = []
|
|
parsed = _parse_mail_fragment(fragment, diagnostics)
|
|
if parsed is None:
|
|
return None, diagnostics, []
|
|
receipt_state = _mail_receipt_capability(
|
|
fragment,
|
|
context,
|
|
parsed.payload,
|
|
diagnostics,
|
|
)
|
|
if receipt_state is None:
|
|
return None, diagnostics, []
|
|
receipt, capability = receipt_state
|
|
resolved, required_data = _resolve_smtp_inputs(
|
|
fragment,
|
|
context,
|
|
parsed,
|
|
receipt,
|
|
capability,
|
|
diagnostics,
|
|
)
|
|
if resolved is None:
|
|
return None, diagnostics, required_data
|
|
target = _resolve_profile_target(
|
|
fragment,
|
|
context,
|
|
parsed,
|
|
receipt,
|
|
diagnostics,
|
|
)
|
|
if target is None:
|
|
return None, diagnostics, required_data
|
|
fragment_ref = (
|
|
fragment.fragment_id or f"{receipt.installation_id}:mail.smtp:{target.slug}"
|
|
)
|
|
return (
|
|
_DesiredSmtpProfile(
|
|
fragment_ref=fragment_ref,
|
|
tenant_id=target.tenant_id,
|
|
scope_type=target.scope_type,
|
|
scope_id=target.scope_id,
|
|
name=target.name,
|
|
slug=target.slug,
|
|
description=target.description,
|
|
inherit_to_lower_scopes=target.inherit_to_lower_scopes,
|
|
smtp=resolved.smtp,
|
|
credential_envelope_id=resolved.credential_envelope_id,
|
|
credential_is_default=target.credential_is_default,
|
|
on_conflict=target.on_conflict,
|
|
capability_source=capability.source,
|
|
installation_id=receipt.installation_id,
|
|
operator_user_id=context.operator_user_id,
|
|
),
|
|
diagnostics,
|
|
required_data,
|
|
)
|
|
|
|
|
|
def _parse_mail_fragment(
|
|
fragment: ConfigurationPackageFragment,
|
|
diagnostics: list[ConfigurationDiagnostic],
|
|
) -> _ParsedMailFragment | None:
|
|
payload = fragment.payload
|
|
if not isinstance(payload, Mapping):
|
|
diagnostics.append(
|
|
_invalid(fragment, "SMTP profile payload must be an object.")
|
|
)
|
|
return None
|
|
unknown_payload = sorted(set(payload) - _PAYLOAD_KEYS)
|
|
if unknown_payload:
|
|
diagnostics.append(_unknown_payload_diagnostic(fragment, unknown_payload))
|
|
profile = _mapping(
|
|
payload.get("profile"),
|
|
field_name="profile",
|
|
fragment=fragment,
|
|
diagnostics=diagnostics,
|
|
)
|
|
smtp = _mapping(
|
|
payload.get("smtp"),
|
|
field_name="smtp",
|
|
fragment=fragment,
|
|
diagnostics=diagnostics,
|
|
)
|
|
input_keys = _mapping(
|
|
payload.get("input_keys"),
|
|
field_name="input_keys",
|
|
fragment=fragment,
|
|
diagnostics=diagnostics,
|
|
)
|
|
_unknown_fields(profile, _PROFILE_KEYS, "profile", fragment, diagnostics)
|
|
unknown_smtp = sorted(set(smtp) - _SMTP_KEYS)
|
|
if unknown_smtp:
|
|
diagnostics.append(_unknown_smtp_diagnostic(fragment, unknown_smtp))
|
|
_unknown_fields(input_keys, _INPUT_KEY_FIELDS, "input_keys", fragment, diagnostics)
|
|
if _has_blockers(diagnostics):
|
|
return None
|
|
return _ParsedMailFragment(
|
|
payload=payload,
|
|
profile=profile,
|
|
smtp=smtp,
|
|
input_keys=input_keys,
|
|
)
|
|
|
|
|
|
def _mail_receipt_capability(
|
|
fragment: ConfigurationPackageFragment,
|
|
context: ConfigurationPreflightContext,
|
|
payload: Mapping[str, Any],
|
|
diagnostics: list[ConfigurationDiagnostic],
|
|
) -> tuple[InfrastructureCapabilityReceipt, InfrastructureCapability] | None:
|
|
if context.infrastructure_receipt_error:
|
|
diagnostics.append(
|
|
ConfigurationDiagnostic(
|
|
severity="blocker",
|
|
code="infrastructure_receipt_invalid",
|
|
message=f"The deployment capability receipt is invalid: {context.infrastructure_receipt_error}",
|
|
module_id="mail",
|
|
object_ref=fragment.fragment_id or SMTP_PROFILE_FRAGMENT,
|
|
resolution="Repair or regenerate the deployment receipt before importing Mail configuration.",
|
|
)
|
|
)
|
|
return None
|
|
receipt = context.infrastructure_receipt
|
|
if receipt is None:
|
|
diagnostics.append(
|
|
ConfigurationDiagnostic(
|
|
severity="blocker",
|
|
code="infrastructure_receipt_missing",
|
|
message="Mail SMTP configuration requires the mounted deployment capability receipt.",
|
|
module_id="mail",
|
|
object_ref=fragment.fragment_id or SMTP_PROFILE_FRAGMENT,
|
|
resolution="Mount the installer-generated capability receipt and rerun preflight.",
|
|
)
|
|
)
|
|
return None
|
|
capability_id = _text(payload.get("capability_id")) or "mail.smtp"
|
|
if capability_id != "mail.smtp":
|
|
diagnostics.append(
|
|
_invalid(
|
|
fragment,
|
|
"Mail SMTP fragments must reference capability 'mail.smtp'.",
|
|
)
|
|
)
|
|
return None
|
|
capability = receipt.capability(capability_id)
|
|
if capability is None:
|
|
diagnostics.append(
|
|
ConfigurationDiagnostic(
|
|
severity="blocker",
|
|
code="infrastructure_capability_missing",
|
|
message="The deployment receipt does not declare Mail SMTP infrastructure.",
|
|
module_id="mail",
|
|
object_ref=capability_id,
|
|
resolution="Regenerate the receipt from a deployment profile that declares Mail infrastructure.",
|
|
)
|
|
)
|
|
return None
|
|
if capability.state == "unavailable":
|
|
diagnostics.append(
|
|
ConfigurationDiagnostic(
|
|
severity="blocker",
|
|
code="infrastructure_capability_unavailable",
|
|
message="The deployment receipt states that SMTP infrastructure is unavailable.",
|
|
module_id="mail",
|
|
object_ref=capability_id,
|
|
resolution="Reconfigure the deployment with test mail or an external SMTP relay before importing this profile.",
|
|
)
|
|
)
|
|
return None
|
|
return receipt, capability
|
|
|
|
|
|
def _resolve_smtp_inputs(
|
|
fragment: ConfigurationPackageFragment,
|
|
context: ConfigurationPreflightContext,
|
|
parsed: _ParsedMailFragment,
|
|
receipt: InfrastructureCapabilityReceipt,
|
|
capability: InfrastructureCapability,
|
|
diagnostics: list[ConfigurationDiagnostic],
|
|
) -> tuple[_ResolvedSmtpInputs | None, list[ConfigurationRequiredData]]:
|
|
prefix = _input_prefix(fragment, receipt.installation_id)
|
|
key_map = {
|
|
field: _text(parsed.input_keys.get(field)) or f"{prefix}.{field}"
|
|
for field in _INPUT_KEY_FIELDS
|
|
}
|
|
endpoint = capability.endpoint
|
|
values = {
|
|
field: _receipt_or_input_value(
|
|
field=field,
|
|
receipt_value=endpoint.get(field),
|
|
payload=parsed.smtp,
|
|
supplied_data=context.supplied_data,
|
|
input_key=key_map[field],
|
|
fragment=fragment,
|
|
diagnostics=diagnostics,
|
|
)
|
|
for field in ("host", "port", "security")
|
|
}
|
|
if values["security"] is None:
|
|
values["security"] = _inferred_security(
|
|
source=capability.source,
|
|
scheme=_text(endpoint.get("scheme")),
|
|
port=values["port"],
|
|
)
|
|
required_data = _smtp_required_data(values, key_map)
|
|
credential_envelope_id = _text(
|
|
parsed.payload.get("credential_envelope_id")
|
|
) or _text(context.supplied_data.get(key_map["credential_envelope_id"]))
|
|
credential_required = bool(capability.secret_refs)
|
|
required_data.append(
|
|
ConfigurationRequiredData(
|
|
key=key_map["credential_envelope_id"],
|
|
label="SMTP credential envelope id",
|
|
data_type="reference:credential-envelope",
|
|
required=credential_required and credential_envelope_id is None,
|
|
secret=False,
|
|
description=(
|
|
"An existing credential envelope is required by the receipt; plaintext credentials are not accepted."
|
|
if credential_required
|
|
else "Optional existing credential envelope; plaintext credentials are not accepted."
|
|
),
|
|
)
|
|
)
|
|
missing = [item for item in required_data if item.required]
|
|
if missing:
|
|
diagnostics.append(
|
|
ConfigurationDiagnostic(
|
|
severity="blocker",
|
|
code="mail_configuration_input_required",
|
|
message=(
|
|
"Mail SMTP configuration still needs reviewed operator data: "
|
|
+ ", ".join(item.label for item in missing)
|
|
+ "."
|
|
),
|
|
module_id="mail",
|
|
object_ref=fragment.fragment_id or SMTP_PROFILE_FRAGMENT,
|
|
resolution="Provide the listed non-secret values or credential-envelope reference and rerun preflight.",
|
|
)
|
|
)
|
|
return None, required_data
|
|
try:
|
|
smtp = SmtpConfig.model_validate(
|
|
{
|
|
**values,
|
|
"timeout_seconds": parsed.smtp.get("timeout_seconds", 30),
|
|
}
|
|
)
|
|
except Exception as exc:
|
|
diagnostics.append(
|
|
_invalid(fragment, f"SMTP server metadata is invalid: {exc}")
|
|
)
|
|
return None, required_data
|
|
return (
|
|
_ResolvedSmtpInputs(
|
|
smtp=smtp,
|
|
credential_envelope_id=credential_envelope_id,
|
|
),
|
|
required_data,
|
|
)
|
|
|
|
|
|
def _resolve_profile_target(
|
|
fragment: ConfigurationPackageFragment,
|
|
context: ConfigurationPreflightContext,
|
|
parsed: _ParsedMailFragment,
|
|
receipt: InfrastructureCapabilityReceipt,
|
|
diagnostics: list[ConfigurationDiagnostic],
|
|
) -> _ProfileTarget | None:
|
|
scope_type = (_text(parsed.profile.get("scope_type")) or "tenant").casefold()
|
|
if scope_type not in {"system", "tenant"}:
|
|
diagnostics.append(
|
|
_invalid(
|
|
fragment,
|
|
"Deployment SMTP profiles may use only system or tenant scope.",
|
|
)
|
|
)
|
|
return None
|
|
if scope_type == "tenant" and not context.tenant_id:
|
|
diagnostics.append(
|
|
ConfigurationDiagnostic(
|
|
severity="blocker",
|
|
code="tenant_required",
|
|
message="A tenant-scoped Mail profile requires a selected tenant.",
|
|
module_id="mail",
|
|
object_ref=fragment.fragment_id or SMTP_PROFILE_FRAGMENT,
|
|
resolution="Select a tenant or review a system-scoped package with system authority.",
|
|
)
|
|
)
|
|
return None
|
|
if scope_type == "system" and not _SYSTEM_CONFIGURATION_SCOPES.intersection(
|
|
context.operator_scopes
|
|
):
|
|
diagnostics.append(
|
|
ConfigurationDiagnostic(
|
|
severity="blocker",
|
|
code="system_configuration_authority_required",
|
|
message="Creating a system-wide Mail profile requires system configuration authority.",
|
|
module_id="mail",
|
|
object_ref=fragment.fragment_id or SMTP_PROFILE_FRAGMENT,
|
|
resolution="Use a tenant profile or ask a system administrator to apply the package.",
|
|
)
|
|
)
|
|
return None
|
|
on_conflict = (_text(parsed.payload.get("on_conflict")) or "preserve").casefold()
|
|
if on_conflict not in {"preserve", "update"}:
|
|
diagnostics.append(
|
|
_invalid(fragment, "on_conflict must be 'preserve' or 'update'.")
|
|
)
|
|
return None
|
|
name = _text(parsed.profile.get("name")) or (
|
|
f"Deployment SMTP ({receipt.installation_id})"
|
|
)
|
|
slug = slugify_profile_name(
|
|
_text(parsed.profile.get("slug"))
|
|
or f"deployment-{receipt.installation_id}-smtp"
|
|
)[:100]
|
|
return _ProfileTarget(
|
|
tenant_id=None if scope_type == "system" else context.tenant_id,
|
|
scope_type=scope_type,
|
|
scope_id=None if scope_type == "system" else context.tenant_id,
|
|
name=name[:255],
|
|
slug=slug,
|
|
description=(
|
|
_text(parsed.profile.get("description"))
|
|
or f"SMTP profile bound to deployment capability receipt {receipt.installation_id}."
|
|
),
|
|
inherit_to_lower_scopes=_bool(
|
|
parsed.profile.get("inherit_to_lower_scopes"),
|
|
default=True,
|
|
),
|
|
credential_is_default=_bool(
|
|
parsed.payload.get("credential_is_default"),
|
|
default=True,
|
|
),
|
|
on_conflict=on_conflict,
|
|
)
|
|
|
|
|
|
def _apply_smtp_profile(
|
|
session: Session,
|
|
desired: _DesiredSmtpProfile,
|
|
) -> ConfigurationApplyResult:
|
|
profile = _profile_by_identity(session, desired)
|
|
created: dict[str, str] = {}
|
|
updated: dict[str, str] = {}
|
|
if profile is None:
|
|
profile = create_mail_server_profile(
|
|
session,
|
|
tenant_id=desired.tenant_id or "system",
|
|
user_id=desired.operator_user_id,
|
|
name=desired.name,
|
|
slug=desired.slug,
|
|
description=desired.description,
|
|
smtp=desired.smtp,
|
|
imap=None,
|
|
is_active=True,
|
|
inherit_to_lower_scopes=desired.inherit_to_lower_scopes,
|
|
scope_type=desired.scope_type,
|
|
scope_id=desired.scope_id,
|
|
)
|
|
initialize_profile_hierarchy(
|
|
session,
|
|
profile=profile,
|
|
smtp=desired.smtp,
|
|
imap=None,
|
|
user_id=desired.operator_user_id,
|
|
)
|
|
created[desired.fragment_ref] = f"mail_profile:{profile.id}"
|
|
else:
|
|
server = _default_smtp_server(session, profile.id)
|
|
changes = _profile_changes(session, profile, server, desired)
|
|
if changes:
|
|
update_mail_server_profile(
|
|
session,
|
|
profile,
|
|
user_id=desired.operator_user_id,
|
|
tenant_id=desired.tenant_id or "system",
|
|
name=desired.name,
|
|
description=desired.description,
|
|
is_active=True if not profile.is_active else None,
|
|
inherit_to_lower_scopes=desired.inherit_to_lower_scopes,
|
|
smtp=desired.smtp,
|
|
)
|
|
sync_default_profile_server(
|
|
session,
|
|
profile=profile,
|
|
protocol="smtp",
|
|
config=desired.smtp_payload,
|
|
user_id=desired.operator_user_id,
|
|
)
|
|
updated[desired.fragment_ref] = f"mail_profile:{profile.id}"
|
|
server = _default_smtp_server(session, profile.id)
|
|
if server is None:
|
|
server = sync_default_profile_server(
|
|
session,
|
|
profile=profile,
|
|
protocol="smtp",
|
|
config=desired.smtp_payload,
|
|
user_id=desired.operator_user_id,
|
|
)
|
|
if desired.credential_envelope_id and server is not None:
|
|
credential = session.get(CredentialEnvelope, desired.credential_envelope_id)
|
|
if credential is None:
|
|
return ConfigurationApplyResult(
|
|
diagnostics=(
|
|
ConfigurationDiagnostic(
|
|
severity="blocker",
|
|
code="mail_credential_envelope_missing",
|
|
message="The reviewed SMTP credential envelope no longer exists.",
|
|
module_id="mail",
|
|
object_ref=desired.fragment_ref,
|
|
resolution="Rerun preflight and select an available credential envelope.",
|
|
),
|
|
)
|
|
)
|
|
binding = _credential_binding(
|
|
session,
|
|
server.id,
|
|
desired.credential_envelope_id,
|
|
)
|
|
was_default = bool(binding and binding.is_default)
|
|
bind_mail_credential(
|
|
session,
|
|
server=server,
|
|
credential=credential,
|
|
user_id=desired.operator_user_id,
|
|
is_default=desired.credential_is_default,
|
|
)
|
|
if binding is None or (desired.credential_is_default and not was_default):
|
|
updated.setdefault(
|
|
desired.fragment_ref,
|
|
f"mail_profile:{profile.id}",
|
|
)
|
|
return ConfigurationApplyResult(created_refs=created, updated_refs=updated)
|
|
|
|
|
|
def _export_smtp_profiles(
|
|
session: Session,
|
|
selection: ConfigurationExportSelection,
|
|
) -> ConfigurationExportResult:
|
|
clauses = []
|
|
if selection.tenant_id:
|
|
clauses.append(
|
|
(MailServerProfile.scope_type == "tenant")
|
|
& (MailServerProfile.scope_id == selection.tenant_id)
|
|
)
|
|
if "system" in selection.scopes:
|
|
clauses.append(MailServerProfile.scope_type == "system")
|
|
if not clauses:
|
|
return ConfigurationExportResult(
|
|
diagnostics=(
|
|
ConfigurationDiagnostic(
|
|
severity="blocker",
|
|
code="tenant_required",
|
|
message="Mail configuration export requires a tenant or system scope.",
|
|
module_id="mail",
|
|
resolution="Select a tenant or explicitly include system scope.",
|
|
),
|
|
)
|
|
)
|
|
statement = select(MailServerProfile)
|
|
if len(clauses) == 1:
|
|
statement = statement.where(clauses[0])
|
|
else:
|
|
statement = statement.where(or_(*clauses))
|
|
requested = {item.removeprefix("mail_profile:") for item in selection.object_refs}
|
|
if requested:
|
|
statement = statement.where(MailServerProfile.id.in_(requested))
|
|
profiles = session.execute(statement.order_by(MailServerProfile.slug)).scalars()
|
|
fragments: list[ConfigurationPackageFragment] = []
|
|
requirements: list[ConfigurationRequiredData] = []
|
|
for profile in profiles:
|
|
server = _default_smtp_server(session, profile.id)
|
|
smtp_payload = dict(
|
|
server.config if server is not None else profile.smtp_config or {}
|
|
)
|
|
for key in ("username", "password", "enabled"):
|
|
smtp_payload.pop(key, None)
|
|
fragment_id = f"mail-profile-{profile.slug}"
|
|
credential_key = f"mail.smtp.{_safe_key(profile.slug)}.credential_envelope_id"
|
|
fragments.append(
|
|
ConfigurationPackageFragment(
|
|
module_id="mail",
|
|
fragment_type=SMTP_PROFILE_FRAGMENT,
|
|
fragment_id=fragment_id,
|
|
payload={
|
|
"capability_id": "mail.smtp",
|
|
"profile": {
|
|
"name": profile.name,
|
|
"slug": profile.slug,
|
|
"description": profile.description,
|
|
"scope_type": profile.scope_type,
|
|
"inherit_to_lower_scopes": bool(
|
|
profile.inherit_to_lower_scopes
|
|
),
|
|
},
|
|
"smtp": smtp_payload,
|
|
"input_keys": {"credential_envelope_id": credential_key},
|
|
"on_conflict": "preserve",
|
|
},
|
|
)
|
|
)
|
|
requirements.append(
|
|
ConfigurationRequiredData(
|
|
key=credential_key,
|
|
label=f"Credential envelope for {profile.name}",
|
|
data_type="reference:credential-envelope",
|
|
required=False,
|
|
description="Select a target-environment credential envelope; credentials are never exported.",
|
|
)
|
|
)
|
|
return ConfigurationExportResult(
|
|
fragments=tuple(fragments),
|
|
data_requirements=tuple(requirements),
|
|
)
|
|
|
|
|
|
def _profile_by_identity(
|
|
session: Session,
|
|
desired: _DesiredSmtpProfile,
|
|
) -> MailServerProfile | None:
|
|
statement = select(MailServerProfile).where(
|
|
MailServerProfile.scope_type == desired.scope_type,
|
|
MailServerProfile.slug == desired.slug,
|
|
)
|
|
if desired.scope_id is None:
|
|
statement = statement.where(MailServerProfile.scope_id.is_(None))
|
|
else:
|
|
statement = statement.where(MailServerProfile.scope_id == desired.scope_id)
|
|
return session.execute(statement).scalar_one_or_none()
|
|
|
|
|
|
def _default_smtp_server(
|
|
session: Session,
|
|
profile_id: str,
|
|
) -> MailServerEndpoint | None:
|
|
return (
|
|
session.execute(
|
|
select(MailServerEndpoint)
|
|
.where(
|
|
MailServerEndpoint.profile_id == profile_id,
|
|
MailServerEndpoint.protocol == "smtp",
|
|
)
|
|
.order_by(
|
|
MailServerEndpoint.is_default.desc(),
|
|
MailServerEndpoint.is_active.desc(),
|
|
MailServerEndpoint.name,
|
|
MailServerEndpoint.id,
|
|
)
|
|
)
|
|
.scalars()
|
|
.first()
|
|
)
|
|
|
|
|
|
def _profile_changes(
|
|
session: Session,
|
|
profile: MailServerProfile,
|
|
server: MailServerEndpoint | None,
|
|
desired: _DesiredSmtpProfile,
|
|
) -> list[str]:
|
|
changes = _profile_metadata_changes(profile, desired)
|
|
changes.extend(_smtp_server_changes(profile, server, desired))
|
|
changes.extend(_credential_binding_changes(session, server, desired))
|
|
return list(dict.fromkeys(changes))
|
|
|
|
|
|
def _profile_metadata_changes(
|
|
profile: MailServerProfile,
|
|
desired: _DesiredSmtpProfile,
|
|
) -> list[str]:
|
|
changes: list[str] = []
|
|
if profile.name != desired.name:
|
|
changes.append("name")
|
|
if (profile.description or None) != (desired.description or None):
|
|
changes.append("description")
|
|
if not profile.is_active:
|
|
changes.append("active state")
|
|
if bool(profile.inherit_to_lower_scopes) != desired.inherit_to_lower_scopes:
|
|
changes.append("scope inheritance")
|
|
return changes
|
|
|
|
|
|
def _smtp_server_changes(
|
|
profile: MailServerProfile,
|
|
server: MailServerEndpoint | None,
|
|
desired: _DesiredSmtpProfile,
|
|
) -> list[str]:
|
|
changes: list[str] = []
|
|
current_payload = dict(
|
|
server.config if server is not None else profile.smtp_config or {}
|
|
)
|
|
for key in ("username", "password", "enabled"):
|
|
current_payload.pop(key, None)
|
|
try:
|
|
current_payload = SmtpServerConfig.model_validate(current_payload).model_dump(
|
|
mode="json"
|
|
)
|
|
except Exception:
|
|
changes.append("SMTP server metadata")
|
|
else:
|
|
if current_payload != desired.smtp_payload:
|
|
changes.append("SMTP server metadata")
|
|
if server is None:
|
|
changes.append("SMTP server hierarchy")
|
|
return changes
|
|
|
|
|
|
def _credential_binding_changes(
|
|
session: Session,
|
|
server: MailServerEndpoint | None,
|
|
desired: _DesiredSmtpProfile,
|
|
) -> list[str]:
|
|
if not desired.credential_envelope_id or server is None:
|
|
return []
|
|
binding = _credential_binding(
|
|
session,
|
|
server.id,
|
|
desired.credential_envelope_id,
|
|
)
|
|
if binding is None:
|
|
return ["credential binding"]
|
|
if desired.credential_is_default and not binding.is_default:
|
|
return ["default credential binding"]
|
|
return []
|
|
|
|
|
|
def _credential_diagnostics(
|
|
session: Session,
|
|
desired: _DesiredSmtpProfile,
|
|
*,
|
|
server: MailServerEndpoint | None,
|
|
) -> list[ConfigurationDiagnostic]:
|
|
credential_id = desired.credential_envelope_id
|
|
if not credential_id:
|
|
return []
|
|
credential = session.get(CredentialEnvelope, credential_id)
|
|
if (
|
|
credential is None
|
|
or credential.deleted_at is not None
|
|
or not credential.is_active
|
|
):
|
|
return [
|
|
ConfigurationDiagnostic(
|
|
severity="blocker",
|
|
code="mail_credential_envelope_missing",
|
|
message="The selected SMTP credential envelope is missing or inactive.",
|
|
module_id="mail",
|
|
object_ref=desired.fragment_ref,
|
|
resolution="Select an active credential envelope visible to this profile scope.",
|
|
)
|
|
]
|
|
server_ref = mail_server_ref(server.id) if server is not None else None
|
|
context = CredentialAccessContext(
|
|
tenant_id=desired.tenant_id,
|
|
target_scope_type=desired.scope_type,
|
|
target_scope_id=desired.scope_id,
|
|
module_id="mail",
|
|
server_ref=server_ref,
|
|
)
|
|
if not credential_visible_to_context(credential, context):
|
|
reason = (
|
|
"A server-restricted credential cannot be bound before the package-owned server exists."
|
|
if server is None and credential.allowed_server_refs
|
|
else "The selected credential is outside the profile scope or is not allowed for Mail and this server."
|
|
)
|
|
return [
|
|
ConfigurationDiagnostic(
|
|
severity="blocker",
|
|
code="mail_credential_envelope_not_available",
|
|
message=reason,
|
|
module_id="mail",
|
|
object_ref=desired.fragment_ref,
|
|
resolution="Choose a compatible credential envelope or update its governed scope and allowed references first.",
|
|
)
|
|
]
|
|
server_tenant_id = desired.tenant_id
|
|
if credential.tenant_id not in {None, server_tenant_id}:
|
|
return [
|
|
ConfigurationDiagnostic(
|
|
severity="blocker",
|
|
code="mail_credential_tenant_mismatch",
|
|
message="The credential envelope and SMTP profile belong to different tenants.",
|
|
module_id="mail",
|
|
object_ref=desired.fragment_ref,
|
|
resolution="Choose a system credential or a credential from the selected tenant.",
|
|
)
|
|
]
|
|
return []
|
|
|
|
|
|
def _credential_binding(
|
|
session: Session,
|
|
server_id: str,
|
|
credential_id: str,
|
|
) -> MailServerCredentialBinding | None:
|
|
return session.execute(
|
|
select(MailServerCredentialBinding).where(
|
|
MailServerCredentialBinding.server_id == server_id,
|
|
MailServerCredentialBinding.credential_id == credential_id,
|
|
)
|
|
).scalar_one_or_none()
|
|
|
|
|
|
def _receipt_or_input_value(
|
|
*,
|
|
field: str,
|
|
receipt_value: object,
|
|
payload: Mapping[str, Any],
|
|
supplied_data: Mapping[str, Any],
|
|
input_key: str,
|
|
fragment: ConfigurationPackageFragment,
|
|
diagnostics: list[ConfigurationDiagnostic],
|
|
) -> object:
|
|
explicit = payload.get(field)
|
|
if receipt_value is not None:
|
|
if explicit is not None and str(explicit) != str(receipt_value):
|
|
diagnostics.append(
|
|
ConfigurationDiagnostic(
|
|
severity="blocker",
|
|
code="infrastructure_endpoint_mismatch",
|
|
message=f"SMTP {field} conflicts with the deployment capability receipt.",
|
|
module_id="mail",
|
|
object_ref=fragment.fragment_id or SMTP_PROFILE_FRAGMENT,
|
|
resolution="Use the receipt endpoint or regenerate the deployment receipt before importing.",
|
|
)
|
|
)
|
|
return receipt_value
|
|
if explicit is not None:
|
|
return explicit
|
|
return supplied_data.get(input_key)
|
|
|
|
|
|
def _inferred_security(*, source: str, scheme: str | None, port: object) -> str | None:
|
|
if source == "installer-managed-test":
|
|
return "plain"
|
|
if scheme in {"smtps", "smtp+tls"} or str(port) == "465":
|
|
return "tls"
|
|
return None
|
|
|
|
|
|
def _smtp_required_data(
|
|
values: Mapping[str, object],
|
|
key_map: Mapping[str, str],
|
|
) -> list[ConfigurationRequiredData]:
|
|
requirements: list[ConfigurationRequiredData] = []
|
|
definitions = (
|
|
("host", "SMTP host", "string", "Non-secret SMTP server hostname."),
|
|
("port", "SMTP port", "integer", "SMTP server port from 1 to 65535."),
|
|
(
|
|
"security",
|
|
"SMTP transport security",
|
|
"enum:plain,starttls,tls",
|
|
"Choose plain, STARTTLS, or implicit TLS.",
|
|
),
|
|
)
|
|
for field, label, data_type, description in definitions:
|
|
value = values.get(field)
|
|
if value is None or (isinstance(value, str) and not value.strip()):
|
|
requirements.append(
|
|
ConfigurationRequiredData(
|
|
key=key_map[field],
|
|
label=label,
|
|
data_type=data_type,
|
|
description=description,
|
|
)
|
|
)
|
|
return requirements
|
|
|
|
|
|
def _unknown_payload_diagnostic(
|
|
fragment: ConfigurationPackageFragment,
|
|
unknown: list[str],
|
|
) -> ConfigurationDiagnostic:
|
|
if _contains_secret_field(unknown):
|
|
return ConfigurationDiagnostic(
|
|
severity="blocker",
|
|
code="mail_configuration_secret_forbidden",
|
|
message="SMTP configuration packages accept credential-envelope references, never inline credentials.",
|
|
module_id="mail",
|
|
object_ref=fragment.fragment_id or SMTP_PROFILE_FRAGMENT,
|
|
resolution="Remove inline credentials and select an existing credential envelope by id.",
|
|
)
|
|
return _invalid(
|
|
fragment,
|
|
f"SMTP profile payload contains unsupported fields: {', '.join(unknown)}.",
|
|
)
|
|
|
|
|
|
def _unknown_smtp_diagnostic(
|
|
fragment: ConfigurationPackageFragment,
|
|
unknown: list[str],
|
|
) -> ConfigurationDiagnostic:
|
|
if _contains_secret_field(unknown):
|
|
return ConfigurationDiagnostic(
|
|
severity="blocker",
|
|
code="mail_configuration_secret_forbidden",
|
|
message=(
|
|
"SMTP configuration packages accept non-secret server metadata "
|
|
"and credential-envelope references only."
|
|
),
|
|
module_id="mail",
|
|
object_ref=fragment.fragment_id or SMTP_PROFILE_FRAGMENT,
|
|
resolution="Remove inline credentials and select an existing credential envelope by id.",
|
|
)
|
|
return _invalid(
|
|
fragment,
|
|
f"SMTP payload contains unsupported fields: {', '.join(unknown)}.",
|
|
)
|
|
|
|
|
|
def _contains_secret_field(fields: list[str]) -> bool:
|
|
return any(
|
|
marker in field.casefold()
|
|
for field in fields
|
|
for marker in ("password", "secret", "token", "username")
|
|
)
|
|
|
|
|
|
def _mapping(
|
|
value: object,
|
|
*,
|
|
field_name: str,
|
|
fragment: ConfigurationPackageFragment,
|
|
diagnostics: list[ConfigurationDiagnostic],
|
|
) -> Mapping[str, Any]:
|
|
if value is None:
|
|
return {}
|
|
if isinstance(value, Mapping):
|
|
return value
|
|
diagnostics.append(_invalid(fragment, f"{field_name} must be an object."))
|
|
return {}
|
|
|
|
|
|
def _unknown_fields(
|
|
value: Mapping[str, Any],
|
|
allowed: frozenset[str],
|
|
field_name: str,
|
|
fragment: ConfigurationPackageFragment,
|
|
diagnostics: list[ConfigurationDiagnostic],
|
|
) -> None:
|
|
unknown = sorted(set(value) - allowed)
|
|
if unknown:
|
|
diagnostics.append(
|
|
_invalid(
|
|
fragment,
|
|
f"{field_name} contains unsupported fields: {', '.join(unknown)}.",
|
|
)
|
|
)
|
|
|
|
|
|
def _input_prefix(fragment: ConfigurationPackageFragment, installation_id: str) -> str:
|
|
return f"mail.smtp.{_safe_key(fragment.fragment_id or installation_id)}"
|
|
|
|
|
|
def _safe_key(value: str) -> str:
|
|
return re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip("-.") or "deployment"
|
|
|
|
|
|
def _text(value: object) -> str | None:
|
|
if value is None:
|
|
return None
|
|
text = str(value).strip()
|
|
return text or None
|
|
|
|
|
|
def _bool(value: object, *, default: bool) -> bool:
|
|
if value is None:
|
|
return default
|
|
if isinstance(value, bool):
|
|
return value
|
|
return str(value).strip().casefold() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
def _has_blockers(diagnostics: list[ConfigurationDiagnostic]) -> bool:
|
|
return any(item.severity == "blocker" for item in diagnostics)
|
|
|
|
|
|
def _invalid(
|
|
fragment: ConfigurationPackageFragment,
|
|
message: str,
|
|
) -> ConfigurationDiagnostic:
|
|
return ConfigurationDiagnostic(
|
|
severity="blocker",
|
|
code="mail_configuration_payload_invalid",
|
|
message=message,
|
|
module_id="mail",
|
|
object_ref=fragment.fragment_id or fragment.fragment_type,
|
|
resolution="Review the Mail configuration-package fragment and rerun preflight.",
|
|
)
|
|
|
|
|
|
def _unsupported(fragment: ConfigurationPackageFragment) -> ConfigurationDiagnostic:
|
|
return ConfigurationDiagnostic(
|
|
severity="blocker",
|
|
code="fragment_type_unsupported",
|
|
message=f"Mail configuration does not support fragment type {fragment.fragment_type!r}.",
|
|
module_id="mail",
|
|
object_ref=fragment.fragment_id or fragment.fragment_type,
|
|
)
|
|
|
|
|
|
def _blocked_plan(
|
|
fragment: ConfigurationPackageFragment,
|
|
summary: str,
|
|
) -> ConfigurationPlanItem:
|
|
return ConfigurationPlanItem(
|
|
action="blocked",
|
|
module_id="mail",
|
|
fragment_type=fragment.fragment_type,
|
|
fragment_id=fragment.fragment_id,
|
|
summary=summary,
|
|
)
|
|
|
|
|
|
__all__ = ["MAIL_CONFIGURATION_CAPABILITY", "SqlMailConfigurationProvider"]
|