Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47c98b3957 | |||
| 3735027b3d | |||
| 2813c671c0 | |||
| 2bccb78960 |
@@ -33,7 +33,7 @@ revision comparison, credential resolution, policy checks, and SMTP/IMAP
|
|||||||
effects inside Mail. Consumer-visible outcomes are sanitized: provider banners,
|
effects inside Mail. Consumer-visible outcomes are sanitized: provider banners,
|
||||||
raw response bytes, hosts, account identities, and credentials are not returned.
|
raw response bytes, hosts, account identities, and credentials are not returned.
|
||||||
|
|
||||||
A remaining observability slice, tracked in [govoplan-mail#17](https://git.add-ideas.de/add-ideas/govoplan-mail/issues/17), is a Mail-owned durable outbox and transport-attempt store with
|
A remaining observability slice, tracked in [govoplan-mail#17](https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/17), is a Mail-owned durable outbox and transport-attempt store with
|
||||||
restricted diagnostic access, retention controls, and correlation identifiers.
|
restricted diagnostic access, retention controls, and correlation identifiers.
|
||||||
Until that exists, Campaign retains only sanitized delivery evidence; raw
|
Until that exists, Campaign retains only sanitized delivery evidence; raw
|
||||||
provider diagnostics must not be copied into consumer records.
|
provider diagnostics must not be copied into consumer records.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ campaign definition, contact database, or general records store.
|
|||||||
| Release reviewer | [Acceptance checklist](#acceptance-checklist) |
|
| Release reviewer | [Acceptance checklist](#acceptance-checklist) |
|
||||||
|
|
||||||
See also [Mail protocol roadmap](MAIL_PROTOCOL_ROADMAP.md) and the Campaign
|
See also [Mail protocol roadmap](MAIL_PROTOCOL_ROADMAP.md) and the Campaign
|
||||||
[Mail profile boundary](https://git.add-ideas.de/add-ideas/govoplan-campaign/src/branch/main/docs/MAIL_PROFILE_BOUNDARY.md).
|
[Mail profile boundary](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/src/branch/main/docs/MAIL_PROFILE_BOUNDARY.md).
|
||||||
|
|
||||||
## Domain ownership
|
## Domain ownership
|
||||||
|
|
||||||
@@ -358,7 +358,7 @@ equivalent canonical audit events. Consumer send/retry/reconciliation evidence
|
|||||||
belongs primarily to the consuming module today. A Mail-owned restricted
|
belongs primarily to the consuming module today. A Mail-owned restricted
|
||||||
provider-attempt ledger with correlation, retention, and unknown-outcome
|
provider-attempt ledger with correlation, retention, and unknown-outcome
|
||||||
reconciliation remains part of the durable outbox work in
|
reconciliation remains part of the durable outbox work in
|
||||||
[`govoplan-mail#17`](https://git.add-ideas.de/add-ideas/govoplan-mail/issues/17).
|
[`govoplan-mail#17`](https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/17).
|
||||||
|
|
||||||
## Acceptance checklist
|
## Acceptance checklist
|
||||||
|
|
||||||
@@ -389,7 +389,7 @@ Before claiming a Mail composition is production-ready:
|
|||||||
|
|
||||||
- Durable, idempotent Campaign report delivery with Mail-owned attempts,
|
- Durable, idempotent Campaign report delivery with Mail-owned attempts,
|
||||||
unknown-outcome reconciliation, and partial-refusal evidence
|
unknown-outcome reconciliation, and partial-refusal evidence
|
||||||
([`govoplan-mail#17`](https://git.add-ideas.de/add-ideas/govoplan-mail/issues/17)).
|
([`govoplan-mail#17`](https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/17)).
|
||||||
- Canonical audit events for profile tests and the remaining profile/policy
|
- Canonical audit events for profile tests and the remaining profile/policy
|
||||||
administration lifecycle, plus an operator-visible Redis-throttling
|
administration lifecycle, plus an operator-visible Redis-throttling
|
||||||
degradation signal.
|
degradation signal.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from govoplan_mail.backend.mail_profiles import (
|
|||||||
_assert_campaign_inherits_profile_credentials,
|
_assert_campaign_inherits_profile_credentials,
|
||||||
assert_campaign_mail_policy_allows_json,
|
assert_campaign_mail_policy_allows_json,
|
||||||
assert_mail_policy_allows_send,
|
assert_mail_policy_allows_send,
|
||||||
|
campaign_mail_owner_context,
|
||||||
campaign_profile_transport_revisions,
|
campaign_profile_transport_revisions,
|
||||||
effective_mail_profile_policy,
|
effective_mail_profile_policy,
|
||||||
ensure_mail_profile_allowed_for_campaign,
|
ensure_mail_profile_allowed_for_campaign,
|
||||||
@@ -19,6 +20,12 @@ from govoplan_mail.backend.mail_profiles import (
|
|||||||
mail_profile_id_from_campaign_json,
|
mail_profile_id_from_campaign_json,
|
||||||
smtp_config_from_profile,
|
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.runtime import configure_runtime
|
||||||
from govoplan_mail.backend.sending.imap import (
|
from govoplan_mail.backend.sending.imap import (
|
||||||
ImapAppendError,
|
ImapAppendError,
|
||||||
@@ -104,6 +111,7 @@ def _authorized_campaign_profile(
|
|||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
campaign_id: str,
|
campaign_id: str,
|
||||||
profile_id: str,
|
profile_id: str,
|
||||||
|
selection: dict[str, str | None] | None = None,
|
||||||
):
|
):
|
||||||
profile = ensure_mail_profile_allowed_for_campaign(
|
profile = ensure_mail_profile_allowed_for_campaign(
|
||||||
session,
|
session,
|
||||||
@@ -113,10 +121,55 @@ def _authorized_campaign_profile(
|
|||||||
require_active=True,
|
require_active=True,
|
||||||
)
|
)
|
||||||
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id)
|
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
|
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(
|
def campaign_profile_delivery_summary(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -125,21 +178,33 @@ def campaign_profile_delivery_summary(
|
|||||||
campaign_id: str | None = None,
|
campaign_id: str | None = None,
|
||||||
owner_user_id: str | None = None,
|
owner_user_id: str | None = None,
|
||||||
owner_group_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]:
|
) -> dict[str, Any]:
|
||||||
"""Return only non-secret capabilities and opaque drift evidence."""
|
"""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:
|
if campaign_id:
|
||||||
profile = _authorized_campaign_profile(
|
profile = _authorized_campaign_profile(
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
profile_id=profile_id,
|
profile_id=profile_id,
|
||||||
|
selection=selection,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
assert_campaign_mail_policy_allows_json(
|
assert_campaign_mail_policy_allows_json(
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
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_user_id=owner_user_id,
|
||||||
owner_group_id=owner_group_id,
|
owner_group_id=owner_group_id,
|
||||||
)
|
)
|
||||||
@@ -149,15 +214,61 @@ def campaign_profile_delivery_summary(
|
|||||||
profile_id=profile_id,
|
profile_id=profile_id,
|
||||||
require_active=True,
|
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)
|
revisions = campaign_profile_transport_revisions(profile)
|
||||||
smtp = profile.smtp_config or {}
|
smtp_config = profile.smtp_config or {}
|
||||||
imap = profile.imap_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 {
|
return {
|
||||||
"mail_profile_id": profile_id,
|
"mail_profile_id": profile_id,
|
||||||
"smtp_available": bool(smtp.get("host") and smtp.get("port")),
|
"smtp_server_id": resolved_smtp_server_id,
|
||||||
"imap_available": bool(imap.get("host") and imap.get("port")),
|
"smtp_credential_id": resolved_smtp_credential_id,
|
||||||
"smtp_transport_revision": revisions["smtp"],
|
"imap_server_id": resolved_imap_server_id,
|
||||||
"imap_transport_revision": revisions["imap"],
|
"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],
|
envelope_recipients: list[str],
|
||||||
from_header: str | None,
|
from_header: str | None,
|
||||||
expected_smtp_transport_revision: str,
|
expected_smtp_transport_revision: str,
|
||||||
|
smtp_server_id: str | None = None,
|
||||||
|
smtp_credential_id: str | None = None,
|
||||||
) -> CampaignSmtpDeliveryResult:
|
) -> CampaignSmtpDeliveryResult:
|
||||||
|
selection = _selection_payload(
|
||||||
|
profile_id=profile_id,
|
||||||
|
smtp_server_id=smtp_server_id,
|
||||||
|
smtp_credential_id=smtp_credential_id,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
profile = _authorized_campaign_profile(
|
profile = _authorized_campaign_profile(
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
profile_id=profile_id,
|
profile_id=profile_id,
|
||||||
|
selection=selection,
|
||||||
)
|
)
|
||||||
except MailProfileError:
|
except MailProfileError:
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
raise SmtpConfigurationError("The selected Mail profile's SMTP configuration is unusable.") from None
|
raise SmtpConfigurationError("The selected Mail profile's SMTP configuration is unusable.") from None
|
||||||
revisions = campaign_profile_transport_revisions(profile)
|
resolved_smtp = None
|
||||||
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,
|
||||||
|
)
|
||||||
|
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(
|
raise MailProfileError(
|
||||||
"The selected Mail profile's SMTP settings changed after this campaign was built. "
|
"The selected Mail profile's SMTP settings changed after this campaign was built. "
|
||||||
"Revalidate and rebuild the campaign before delivery."
|
"Revalidate and rebuild the campaign before delivery."
|
||||||
)
|
)
|
||||||
try:
|
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)
|
smtp = smtp_config_from_profile(profile)
|
||||||
except MailProfileError:
|
except MailProfileError:
|
||||||
raise
|
raise
|
||||||
@@ -202,7 +352,7 @@ def send_campaign_email_bytes(
|
|||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
smtp=smtp,
|
smtp=smtp,
|
||||||
imap=profile.imap_config or None,
|
imap=None,
|
||||||
envelope_sender=envelope_from,
|
envelope_sender=envelope_from,
|
||||||
from_header=from_header,
|
from_header=from_header,
|
||||||
recipients=envelope_recipients,
|
recipients=envelope_recipients,
|
||||||
@@ -241,30 +391,82 @@ def append_campaign_message_to_sent(
|
|||||||
folder: str | None,
|
folder: str | None,
|
||||||
expected_smtp_transport_revision: str,
|
expected_smtp_transport_revision: str,
|
||||||
expected_imap_transport_revision: str | None,
|
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:
|
) -> 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:
|
try:
|
||||||
profile = _authorized_campaign_profile(
|
profile = _authorized_campaign_profile(
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
profile_id=profile_id,
|
profile_id=profile_id,
|
||||||
|
selection=selection,
|
||||||
)
|
)
|
||||||
except MailProfileError:
|
except MailProfileError:
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
raise ImapConfigurationError("The selected Mail profile's IMAP configuration is unusable.") from None
|
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)
|
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(
|
raise MailProfileError(
|
||||||
"The selected Mail profile's SMTP settings changed after this campaign was built. "
|
"The selected Mail profile's SMTP settings changed after this campaign was built. "
|
||||||
"Revalidate and rebuild the campaign before append-to-Sent delivery."
|
"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(
|
raise MailProfileError(
|
||||||
"The selected Mail profile's IMAP settings changed after this campaign was built. "
|
"The selected Mail profile's IMAP settings changed after this campaign was built. "
|
||||||
"Revalidate and rebuild the campaign before append-to-Sent delivery."
|
"Revalidate and rebuild the campaign before append-to-Sent delivery."
|
||||||
)
|
)
|
||||||
try:
|
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)
|
imap = imap_config_from_profile(profile)
|
||||||
except MailProfileError:
|
except MailProfileError:
|
||||||
raise
|
raise
|
||||||
@@ -277,7 +479,7 @@ def append_campaign_message_to_sent(
|
|||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
smtp=profile.smtp_config or None,
|
smtp=None,
|
||||||
imap=imap,
|
imap=imap,
|
||||||
)
|
)
|
||||||
except MailProfileError:
|
except MailProfileError:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, Integer
|
|||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from govoplan_core.db.base import Base, TimestampMixin
|
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:
|
def new_uuid() -> str:
|
||||||
@@ -30,6 +31,7 @@ class MailServerProfile(Base, TimestampMixin):
|
|||||||
slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
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_config: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
smtp_username: Mapped[str | None] = mapped_column(String(320))
|
smtp_username: Mapped[str | None] = mapped_column(String(320))
|
||||||
smtp_password_encrypted: Mapped[str | None] = mapped_column(Text)
|
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)
|
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):
|
class MailProfilePolicy(Base, TimestampMixin):
|
||||||
__tablename__ = "mail_profile_policies"
|
__tablename__ = "mail_profile_policies"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import fnmatch
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass, field
|
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 import and_, or_, select, text
|
||||||
from sqlalchemy.orm import Session
|
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(
|
def mail_profile_scope_visible_to_actor(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -1335,7 +1348,15 @@ def _profile_has_transport(profile: MailServerProfile, protocol: str) -> bool:
|
|||||||
return bool(profile.imap_config)
|
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:
|
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:
|
if unexpected:
|
||||||
paths = ", ".join(f"server.{key}" for key in unexpected)
|
paths = ", ".join(f"server.{key}" for key in unexpected)
|
||||||
raise MailProfileError(
|
raise MailProfileError(
|
||||||
"Campaign JSON may only reference a Mail-owned profile through server.mail_profile_id; "
|
"Campaign JSON may only reference Mail-owned profile, server, and credential identifiers; "
|
||||||
f"remove campaign-local SMTP/IMAP settings ({paths}), select a Mail profile, and save a new campaign version."
|
f"remove campaign-local SMTP/IMAP settings ({paths}), select Mail resources, and save a new campaign version."
|
||||||
)
|
)
|
||||||
value = server.get("mail_profile_id")
|
value = server.get("mail_profile_id")
|
||||||
if value is None:
|
if value is None:
|
||||||
@@ -1354,18 +1375,63 @@ def _campaign_mail_profile_reference_id(server: dict[str, Any]) -> str | None:
|
|||||||
return value.strip()
|
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(
|
def _assert_campaign_inherits_profile_credentials(
|
||||||
profile: MailServerProfile,
|
profile: MailServerProfile,
|
||||||
policy: EffectiveMailProfilePolicy,
|
policy: EffectiveMailProfilePolicy,
|
||||||
|
selection: Mapping[str, str | None] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
for protocol in ("smtp", "imap"):
|
for protocol in ("smtp", "imap"):
|
||||||
if not _profile_has_transport(profile, protocol):
|
if not _profile_has_transport(profile, protocol):
|
||||||
continue
|
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(
|
raise MailProfileError(
|
||||||
f"Campaign delivery cannot use the selected profile because the effective {protocol.upper()} "
|
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 "
|
"credential policy requires an explicit credential selection for this campaign."
|
||||||
"and change the policy to inherit them."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1379,8 +1445,8 @@ def assert_campaign_mail_policy_allows_json(
|
|||||||
owner_group_id: str | None = None,
|
owner_group_id: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
data = raw_json if isinstance(raw_json, dict) else {}
|
data = raw_json if isinstance(raw_json, dict) else {}
|
||||||
server = data.get("server") if isinstance(data.get("server"), dict) else {}
|
selection = campaign_mail_selection_from_json(data)
|
||||||
profile_id = _campaign_mail_profile_reference_id(server)
|
profile_id = selection["mail_profile_id"]
|
||||||
if profile_id:
|
if profile_id:
|
||||||
if campaign_id:
|
if campaign_id:
|
||||||
profile = ensure_mail_profile_allowed_for_campaign(
|
profile = ensure_mail_profile_allowed_for_campaign(
|
||||||
@@ -1391,7 +1457,7 @@ def assert_campaign_mail_policy_allows_json(
|
|||||||
require_active=True,
|
require_active=True,
|
||||||
)
|
)
|
||||||
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id)
|
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
|
return
|
||||||
profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=str(profile_id), require_active=True)
|
profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=str(profile_id), require_active=True)
|
||||||
policy = effective_mail_profile_policy(
|
policy = effective_mail_profile_policy(
|
||||||
@@ -1408,7 +1474,7 @@ def assert_campaign_mail_policy_allows_json(
|
|||||||
owner_group_id=owner_group_id,
|
owner_group_id=owner_group_id,
|
||||||
):
|
):
|
||||||
raise MailProfileError("Mail-server profile is not allowed by the effective policy")
|
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
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1424,6 +1490,7 @@ def create_mail_server_profile(
|
|||||||
smtp: SmtpConfig,
|
smtp: SmtpConfig,
|
||||||
imap: ImapConfig | None,
|
imap: ImapConfig | None,
|
||||||
is_active: bool = True,
|
is_active: bool = True,
|
||||||
|
inherit_to_lower_scopes: bool = True,
|
||||||
scope_type: str = "tenant",
|
scope_type: str = "tenant",
|
||||||
scope_id: str | None = None,
|
scope_id: str | None = None,
|
||||||
) -> MailServerProfile:
|
) -> MailServerProfile:
|
||||||
@@ -1466,6 +1533,7 @@ def create_mail_server_profile(
|
|||||||
slug=clean_slug,
|
slug=clean_slug,
|
||||||
description=description,
|
description=description,
|
||||||
is_active=is_active,
|
is_active=is_active,
|
||||||
|
inherit_to_lower_scopes=bool(inherit_to_lower_scopes),
|
||||||
smtp_config=smtp_payload,
|
smtp_config=smtp_payload,
|
||||||
smtp_username=smtp_username,
|
smtp_username=smtp_username,
|
||||||
smtp_password_encrypted=encrypt_secret(smtp_password),
|
smtp_password_encrypted=encrypt_secret(smtp_password),
|
||||||
@@ -1491,6 +1559,7 @@ def update_mail_server_profile(
|
|||||||
slug: str | None = None,
|
slug: str | None = None,
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
is_active: bool | None = None,
|
is_active: bool | None = None,
|
||||||
|
inherit_to_lower_scopes: bool | None = None,
|
||||||
smtp: SmtpConfig | None = None,
|
smtp: SmtpConfig | None = None,
|
||||||
imap: ImapConfig | None = None,
|
imap: ImapConfig | None = None,
|
||||||
clear_imap: bool = False,
|
clear_imap: bool = False,
|
||||||
@@ -1538,6 +1607,8 @@ def update_mail_server_profile(
|
|||||||
profile.description = description
|
profile.description = description
|
||||||
if is_active is not None:
|
if is_active is not None:
|
||||||
profile.is_active = is_active
|
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)
|
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)
|
_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,
|
"slug": profile.slug,
|
||||||
"description": profile.description,
|
"description": profile.description,
|
||||||
"is_active": profile.is_active,
|
"is_active": profile.is_active,
|
||||||
|
"inherit_to_lower_scopes": bool(
|
||||||
|
getattr(profile, "inherit_to_lower_scopes", True)
|
||||||
|
),
|
||||||
"smtp": _server_config_payload(profile.smtp_config),
|
"smtp": _server_config_payload(profile.smtp_config),
|
||||||
"imap": _server_config_payload(profile.imap_config) if profile.imap_config else None,
|
"imap": _server_config_payload(profile.imap_config) if profile.imap_config else None,
|
||||||
"credentials": {
|
"credentials": {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from govoplan_core.core.modules import (
|
|||||||
DocumentationLink,
|
DocumentationLink,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
|
FrontendRoute,
|
||||||
MigrationSpec,
|
MigrationSpec,
|
||||||
ModuleContext,
|
ModuleContext,
|
||||||
ModuleInterfaceProvider,
|
ModuleInterfaceProvider,
|
||||||
@@ -21,12 +22,15 @@ from govoplan_core.core.modules import (
|
|||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.views import ViewSurface
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_mail.backend.documentation import documentation_topics
|
from govoplan_mail.backend.documentation import documentation_topics
|
||||||
from govoplan_mail.backend.db import models as mail_models # noqa: F401 - populate Mail ORM metadata
|
from govoplan_mail.backend.db import models as mail_models # noqa: F401 - populate Mail ORM metadata
|
||||||
|
|
||||||
|
|
||||||
_mail_table_retirement_provider = drop_table_retirement_provider(
|
_mail_table_retirement_provider = drop_table_retirement_provider(
|
||||||
|
mail_models.MailServerCredentialBinding,
|
||||||
|
mail_models.MailServerEndpoint,
|
||||||
mail_models.MailServerProfile,
|
mail_models.MailServerProfile,
|
||||||
mail_models.MailProfilePolicy,
|
mail_models.MailProfilePolicy,
|
||||||
mail_models.MailMailboxFolderIndex,
|
mail_models.MailMailboxFolderIndex,
|
||||||
@@ -182,7 +186,22 @@ manifest = ModuleManifest(
|
|||||||
frontend=FrontendModule(
|
frontend=FrontendModule(
|
||||||
module_id="mail",
|
module_id="mail",
|
||||||
package_name="@govoplan/mail-webui",
|
package_name="@govoplan/mail-webui",
|
||||||
|
routes=(
|
||||||
|
FrontendRoute(
|
||||||
|
path="/mail",
|
||||||
|
component="MailboxPage",
|
||||||
|
required_any=("mail:mailbox:read",),
|
||||||
|
order=50,
|
||||||
|
),
|
||||||
|
),
|
||||||
nav_items=(NavItem(path="/mail", label="Mail", icon="mail", required_any=("mail:mailbox:read",), order=50),),
|
nav_items=(NavItem(path="/mail", label="Mail", icon="mail", required_any=("mail:mailbox:read",), order=50),),
|
||||||
|
view_surfaces=(
|
||||||
|
ViewSurface(id="mail.admin.system-servers", module_id="mail", kind="section", label="System mail servers", order=70),
|
||||||
|
ViewSurface(id="mail.admin.tenant-servers", module_id="mail", kind="section", label="Tenant mail servers", order=60),
|
||||||
|
ViewSurface(id="mail.admin.group-servers", module_id="mail", kind="section", label="Group mail servers", order=20),
|
||||||
|
ViewSurface(id="mail.admin.user-servers", module_id="mail", kind="section", label="User mail servers", order=20),
|
||||||
|
ViewSurface(id="mail.settings.profiles", module_id="mail", kind="section", label="Personal mail profiles", order=10),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migration_spec=MigrationSpec(
|
migration_spec=MigrationSpec(
|
||||||
module_id="mail",
|
module_id="mail",
|
||||||
@@ -194,6 +213,8 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
uninstall_guard_providers=(
|
uninstall_guard_providers=(
|
||||||
persistent_table_uninstall_guard(
|
persistent_table_uninstall_guard(
|
||||||
|
mail_models.MailServerCredentialBinding,
|
||||||
|
mail_models.MailServerEndpoint,
|
||||||
mail_models.MailServerProfile,
|
mail_models.MailServerProfile,
|
||||||
mail_models.MailProfilePolicy,
|
mail_models.MailProfilePolicy,
|
||||||
mail_models.MailMailboxFolderIndex,
|
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 datetime import datetime
|
||||||
from typing import Any, Literal
|
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_core.api.v1.schemas import DeltaDeletedItem
|
||||||
from govoplan_mail.backend.config import (
|
from govoplan_mail.backend.config import (
|
||||||
@@ -103,6 +103,7 @@ class MailServerProfileCreateRequest(BaseModel):
|
|||||||
slug: str | None = Field(default=None, max_length=100)
|
slug: str | None = Field(default=None, max_length=100)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
|
inherit_to_lower_scopes: bool = True
|
||||||
scope_type: MailProfileScope = "tenant"
|
scope_type: MailProfileScope = "tenant"
|
||||||
scope_id: str | None = None
|
scope_id: str | None = None
|
||||||
smtp: SmtpServerConfig
|
smtp: SmtpServerConfig
|
||||||
@@ -137,6 +138,7 @@ class MailServerProfileUpdateRequest(BaseModel):
|
|||||||
slug: str | None = Field(default=None, max_length=100)
|
slug: str | None = Field(default=None, max_length=100)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
is_active: bool | None = None
|
is_active: bool | None = None
|
||||||
|
inherit_to_lower_scopes: bool | None = None
|
||||||
smtp: SmtpServerConfig | None = None
|
smtp: SmtpServerConfig | None = None
|
||||||
imap: ImapServerConfig | None = None
|
imap: ImapServerConfig | None = None
|
||||||
credentials: MailServerProfileCredentialsPayload = Field(default_factory=MailServerProfileCredentialsPayload)
|
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):
|
class MailServerProfileResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
tenant_id: str | None = None
|
tenant_id: str | None = None
|
||||||
@@ -176,11 +299,13 @@ class MailServerProfileResponse(BaseModel):
|
|||||||
slug: str
|
slug: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
is_active: bool
|
is_active: bool
|
||||||
|
inherit_to_lower_scopes: bool = True
|
||||||
smtp: dict[str, Any]
|
smtp: dict[str, Any]
|
||||||
imap: dict[str, Any] | None = None
|
imap: dict[str, Any] | None = None
|
||||||
credentials: dict[str, Any] = Field(default_factory=dict)
|
credentials: dict[str, Any] = Field(default_factory=dict)
|
||||||
smtp_password_configured: bool = False
|
smtp_password_configured: bool = False
|
||||||
imap_password_configured: bool = False
|
imap_password_configured: bool = False
|
||||||
|
servers: list[MailServerEndpointResponse] = Field(default_factory=list)
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_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,
|
_assert_campaign_inherits_profile_credentials,
|
||||||
_campaign_mail_profile_reference_id,
|
_campaign_mail_profile_reference_id,
|
||||||
_apply_profile_transport_update,
|
_apply_profile_transport_update,
|
||||||
|
campaign_mail_selection_from_json,
|
||||||
campaign_profile_transport_revisions,
|
campaign_profile_transport_revisions,
|
||||||
create_mail_server_profile,
|
create_mail_server_profile,
|
||||||
_merge_policy,
|
_merge_policy,
|
||||||
@@ -496,14 +497,26 @@ class MailProfileTransportHelperTests(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class MailProfilePolicyHelperTests(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(
|
self.assertEqual(
|
||||||
_campaign_mail_profile_reference_id({"mail_profile_id": " profile-1 "}),
|
_campaign_mail_profile_reference_id({"mail_profile_id": " profile-1 "}),
|
||||||
"profile-1",
|
"profile-1",
|
||||||
)
|
)
|
||||||
for legacy in ("smtp", "imap", "credentials", "inherit_smtp_credentials"):
|
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: {}})
|
_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):
|
def test_campaign_delivery_fails_when_policy_requires_local_credentials(self):
|
||||||
profile = SimpleNamespace(imap_config=None)
|
profile = SimpleNamespace(imap_config=None)
|
||||||
@@ -511,7 +524,7 @@ class MailProfilePolicyHelperTests(unittest.TestCase):
|
|||||||
smtp_credentials=EffectiveCredentialPolicy(inherit=False),
|
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)
|
_assert_campaign_inherits_profile_credentials(profile, policy)
|
||||||
|
|
||||||
def test_merge_policy_respects_locked_lower_level_limits(self):
|
def test_merge_policy_respects_locked_lower_level_limits(self):
|
||||||
|
|||||||
@@ -409,6 +409,7 @@ class ProfileActorAuthorizationTests(unittest.TestCase):
|
|||||||
"govoplan_mail.backend.router._profile_response",
|
"govoplan_mail.backend.router._profile_response",
|
||||||
return_value=profile,
|
return_value=profile,
|
||||||
),
|
),
|
||||||
|
patch("govoplan_mail.backend.router.sync_default_profile_server"),
|
||||||
):
|
):
|
||||||
result = router.update_profile(
|
result = router.update_profile(
|
||||||
profile.id,
|
profile.id,
|
||||||
@@ -446,6 +447,7 @@ class ProfileActorAuthorizationTests(unittest.TestCase):
|
|||||||
"govoplan_mail.backend.router._profile_response",
|
"govoplan_mail.backend.router._profile_response",
|
||||||
return_value=profile,
|
return_value=profile,
|
||||||
),
|
),
|
||||||
|
patch("govoplan_mail.backend.router.sync_default_profile_server"),
|
||||||
):
|
):
|
||||||
router.update_profile(
|
router.update_profile(
|
||||||
profile.id,
|
profile.id,
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ class MailProfileDeletionRouteTests(unittest.TestCase):
|
|||||||
with (
|
with (
|
||||||
patch("govoplan_mail.backend.router._require_profile_write_scope"),
|
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.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._record_mail_change") as create_change,
|
||||||
patch("govoplan_mail.backend.router._profile_response", return_value=system_profile),
|
patch("govoplan_mail.backend.router._profile_response", return_value=system_profile),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import type {
|
|||||||
MailConnectionTestResponse,
|
MailConnectionTestResponse,
|
||||||
MailImapFolderListResponse,
|
MailImapFolderListResponse,
|
||||||
MailImapTestPayload,
|
MailImapTestPayload,
|
||||||
|
MailCredentialEnvelope,
|
||||||
MailProfilePolicy,
|
MailProfilePolicy,
|
||||||
MailProfilePolicyResponse,
|
MailProfilePolicyResponse,
|
||||||
MailProfileScope,
|
MailProfileScope,
|
||||||
MailSecurity,
|
MailSecurity,
|
||||||
|
MailServerEndpoint,
|
||||||
MailServerProfile,
|
MailServerProfile,
|
||||||
MailServerProfilePayload,
|
MailServerProfilePayload,
|
||||||
MailSmtpTestPayload,
|
MailSmtpTestPayload,
|
||||||
@@ -18,6 +20,7 @@ import { apiFetch, apiGetList, apiPath, apiPost, apiPostJson } from "./client";
|
|||||||
export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "@govoplan/core-webui";
|
export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "@govoplan/core-webui";
|
||||||
export type {
|
export type {
|
||||||
MailConnectionTestResponse,
|
MailConnectionTestResponse,
|
||||||
|
MailCredentialEnvelope,
|
||||||
MailCredentialPolicy,
|
MailCredentialPolicy,
|
||||||
MailImapFolderListResponse,
|
MailImapFolderListResponse,
|
||||||
MailImapFolderResponse,
|
MailImapFolderResponse,
|
||||||
@@ -30,6 +33,7 @@ export type {
|
|||||||
MailProfilePolicyResponse,
|
MailProfilePolicyResponse,
|
||||||
MailProfileScope,
|
MailProfileScope,
|
||||||
MailSecurity,
|
MailSecurity,
|
||||||
|
MailServerEndpoint,
|
||||||
MailServerProfile,
|
MailServerProfile,
|
||||||
MailServerProfileCredentialsPayload,
|
MailServerProfileCredentialsPayload,
|
||||||
MailServerProfileListResponse,
|
MailServerProfileListResponse,
|
||||||
@@ -176,6 +180,33 @@ export async function createMailServerProfile(settings: ApiSettings, payload: Ma
|
|||||||
|
|
||||||
export type MailServerProfileUpdatePayload = Partial<MailServerProfilePayload> & { clear_imap?: boolean };
|
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> {
|
export async function updateMailServerProfile(settings: ApiSettings, profileId: string, payload: MailServerProfileUpdatePayload): Promise<MailServerProfile> {
|
||||||
return apiFetch<MailServerProfile>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}`, {
|
return apiFetch<MailServerProfile>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}`, {
|
||||||
method: "PATCH",
|
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" });
|
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(
|
export async function getMailProfilePolicy(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
scopeType: MailProfileScope,
|
scopeType: MailProfileScope,
|
||||||
@@ -211,16 +354,55 @@ export async function updateMailProfilePolicy(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
|
export async function testMailProfileSmtp(
|
||||||
return apiPost<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-smtp`);
|
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> {
|
export async function testMailProfileImap(
|
||||||
return apiPost<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-imap`);
|
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> {
|
export async function listMailProfileImapFolders(
|
||||||
return apiPost<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`);
|
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> {
|
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 =
|
export type MailProfileEditTarget =
|
||||||
| {kind: "create";}
|
| {kind: "create";}
|
||||||
| {kind: "profile";}
|
| {kind: "profile";}
|
||||||
| {kind: "server";protocol: MailProfileProtocol;}
|
| {kind: "server";protocol: MailProfileProtocol;serverId?: string;}
|
||||||
| {kind: "credentials";protocol: MailProfileProtocol;};
|
| {kind: "credentials";protocol: MailProfileProtocol;serverId?: string;credentialId?: string;};
|
||||||
|
|
||||||
export type MailProfileTransportLike = {
|
export type MailProfileTransportLike = {
|
||||||
host?: string | null;
|
host?: string | null;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-mail.bytes_b": "{value0} B",
|
"i18n:govoplan-mail.bytes_b": "{value0} B",
|
||||||
"i18n:govoplan-mail.bytes_kb": "{value0} KB",
|
"i18n:govoplan-mail.bytes_kb": "{value0} KB",
|
||||||
"i18n:govoplan-mail.bytes_mb": "{value0} MB",
|
"i18n:govoplan-mail.bytes_mb": "{value0} MB",
|
||||||
"i18n:govoplan-mail.campaign_local_settings.920ecb62": "campaign-scoped profiles",
|
"i18n:govoplan-mail.campaign_local_settings.920ecb62": "profiles scoped to campaigns",
|
||||||
"i18n:govoplan-mail.campaign_local_settings.eb0f1061": "Campaign-scoped profiles",
|
"i18n:govoplan-mail.campaign_local_settings.eb0f1061": "Campaign-scoped profiles",
|
||||||
"i18n:govoplan-mail.campaigns": "Campaigns",
|
"i18n:govoplan-mail.campaigns": "Campaigns",
|
||||||
"i18n:govoplan-mail.campaign.69390e16": "Campaign",
|
"i18n:govoplan-mail.campaign.69390e16": "Campaign",
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ export const mailModule: PlatformWebModule = {
|
|||||||
dependencies: ["access"],
|
dependencies: ["access"],
|
||||||
optionalDependencies: ["addresses"],
|
optionalDependencies: ["addresses"],
|
||||||
translations,
|
translations,
|
||||||
|
viewSurfaces: [
|
||||||
|
{ id: "mail.admin.system-servers", moduleId: "mail", kind: "section", label: "System mail servers", order: 70 },
|
||||||
|
{ id: "mail.admin.tenant-servers", moduleId: "mail", kind: "section", label: "Tenant mail servers", order: 60 },
|
||||||
|
{ id: "mail.admin.group-servers", moduleId: "mail", kind: "section", label: "Group mail servers", order: 20 },
|
||||||
|
{ id: "mail.admin.user-servers", moduleId: "mail", kind: "section", label: "User mail servers", order: 20 },
|
||||||
|
{ id: "mail.settings.profiles", moduleId: "mail", kind: "section", label: "Personal mail profiles", order: 10 }
|
||||||
|
],
|
||||||
navItems: [{ to: "/mail", label: "i18n:govoplan-mail.mail.92379cbb", iconName: "mail", anyOf: mailboxRead, order: 50 }],
|
navItems: [{ to: "/mail", label: "i18n:govoplan-mail.mail.92379cbb", iconName: "mail", anyOf: mailboxRead, order: 50 }],
|
||||||
routes: [
|
routes: [
|
||||||
{ path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings }) => createElement(MailboxPage, { settings }) }],
|
{ path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings }) => createElement(MailboxPage, { settings }) }],
|
||||||
|
|||||||
Reference in New Issue
Block a user