Compare commits
12 Commits
e6a3617a94
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cb6719b91 | |||
| b8029c24d6 | |||
| dec5a4e350 | |||
| 47c98b3957 | |||
| 3735027b3d | |||
| 2813c671c0 | |||
| 2bccb78960 | |||
| 3e23029090 | |||
| 64009471bb | |||
| 2e5921b3ff | |||
| 9f49591a98 | |||
| 193898b00d |
23
README.md
23
README.md
@@ -11,7 +11,7 @@ GovOPlaN Mail is the mail transport module. It owns reusable SMTP/IMAP profile m
|
|||||||
This repository owns:
|
This repository owns:
|
||||||
|
|
||||||
- backend module manifest `mail`
|
- backend module manifest `mail`
|
||||||
- mail permissions such as `mail:profile:read`, `mail:profile:write`, `mail:profile:use`, `mail:profile:test`, and `mail:mailbox:read`
|
- mail permissions such as `mail:profile:read`, `mail:profile:write_own`, `mail:profile:write`, `mail:profile:use`, `mail:profile:test`, and `mail:mailbox:read`
|
||||||
- SMTP/IMAP profile models, policy checks, encrypted credential storage, and profile resolution
|
- SMTP/IMAP profile models, policy checks, encrypted credential storage, and profile resolution
|
||||||
- SMTP send and IMAP append adapters, including mock transports for development
|
- SMTP send and IMAP append adapters, including mock transports for development
|
||||||
- development mock mailbox endpoints used by test-send flows
|
- development mock mailbox endpoints used by test-send flows
|
||||||
@@ -33,10 +33,13 @@ 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
|
Mail also owns a durable delivery-command outbox for effects that do not
|
||||||
restricted diagnostic access, retention controls, and correlation identifiers.
|
already have a consumer-owned job ledger, including Campaign report messages.
|
||||||
Until that exists, Campaign retains only sanitized delivery evidence; raw
|
It persists the command and attempt before SMTP, binds idempotency keys to
|
||||||
provider diagnostics must not be copied into consumer records.
|
canonical request hashes, distinguishes partial refusal and unknown outcome,
|
||||||
|
and requires explicit evidence-backed reconciliation before any deliberate
|
||||||
|
resend. Business readers receive only counts and sanitized state; recipient
|
||||||
|
refusal details require `mail:delivery:diagnostic`.
|
||||||
|
|
||||||
SMTP effects decrypt only SMTP credentials; Sent-folder effects decrypt only
|
SMTP effects decrypt only SMTP credentials; Sent-folder effects decrypt only
|
||||||
IMAP credentials. A connection loss after an effect starts is surfaced as an
|
IMAP credentials. A connection loss after an effect starts is surfaced as an
|
||||||
@@ -55,6 +58,16 @@ a non-secret audit event. Destructive module retirement applies the same rule
|
|||||||
to every remaining profile before any Mail table is dropped; a scrub or audit
|
to every remaining profile before any Mail table is dropped; a scrub or audit
|
||||||
failure blocks retirement.
|
failure blocks retirement.
|
||||||
|
|
||||||
|
Personal profile self-service is a distinct authorization path. An actor with
|
||||||
|
`mail:profile:write_own` can mutate only a user-scoped profile whose scope id is
|
||||||
|
their current tenant membership id; `mail:secret:manage_own` applies the same
|
||||||
|
ownership check to credentials. Neither scope permits profile-policy changes or
|
||||||
|
management of tenant, group, campaign, system, or another user's profiles.
|
||||||
|
Changing an SMTP/IMAP endpoint while a stored password remains is a secret
|
||||||
|
operation and requires the matching credential permission. Deactivating a
|
||||||
|
credential-free profile needs only profile-write authority; if a password will
|
||||||
|
be scrubbed, credential authority is required and the deletion is audited.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Install through the core environment:
|
Install through the core environment:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -82,10 +82,20 @@ effect.
|
|||||||
|
|
||||||
### Provider outcomes
|
### Provider outcomes
|
||||||
|
|
||||||
SMTP acceptance, recipient refusal, temporary/permanent failure, connection
|
SMTP acceptance, partial or complete recipient refusal, temporary/permanent
|
||||||
loss, and unknown outcome are distinct. IMAP append is a separate operation and
|
failure, connection loss, and unknown outcome are distinct. IMAP append is a
|
||||||
outcome. Mail returns a sanitized transport result; the consuming domain owns
|
separate operation and outcome. Ordinary Campaign recipient delivery retains
|
||||||
the durable business job and its retry/reconciliation semantics.
|
its Campaign-owned job ledger. Mail owns an encrypted durable command and
|
||||||
|
attempt ledger for report delivery and other effects that do not have such a
|
||||||
|
consumer ledger.
|
||||||
|
|
||||||
|
The Mail worker commits an attempt before it starts SMTP and then commits an
|
||||||
|
effect-start marker before opening the provider effect. Worker redelivery may
|
||||||
|
recover a stale pre-effect claim, but an accepted, partially accepted,
|
||||||
|
in-progress-after-effect, or unknown command is never sent automatically.
|
||||||
|
Unknown outcomes require a separately authorized reconciliation with an
|
||||||
|
external evidence reference. A deliberate resend creates a new command with a
|
||||||
|
new idempotency key and links it to the prior command.
|
||||||
|
|
||||||
## User tasks
|
## User tasks
|
||||||
|
|
||||||
@@ -114,6 +124,10 @@ non-production provider and mailbox first. A successful connection test does
|
|||||||
not prove policy authorization for a later Campaign context, deliverability,
|
not prove policy authorization for a later Campaign context, deliverability,
|
||||||
recipient acceptance, SPF/DKIM/DMARC alignment, or future availability.
|
recipient acceptance, SPF/DKIM/DMARC alignment, or future availability.
|
||||||
|
|
||||||
|
Testing a saved profile requires both `mail:profile:test` and
|
||||||
|
`mail:profile:use`, and the profile must be active. Profile creation or test
|
||||||
|
authority alone is not enough.
|
||||||
|
|
||||||
Raw settings test endpoints accept new settings only for actors who may both
|
Raw settings test endpoints accept new settings only for actors who may both
|
||||||
test profiles and manage secrets. They are an administration aid, not a way for
|
test profiles and manage secrets. They are an administration aid, not a way for
|
||||||
ordinary consumers to bypass reusable profiles.
|
ordinary consumers to bypass reusable profiles.
|
||||||
@@ -138,17 +152,39 @@ The supplied templates are:
|
|||||||
|
|
||||||
- **Mail profile user:** read/use/test approved profiles and read permitted
|
- **Mail profile user:** read/use/test approved profiles and read permitted
|
||||||
mailboxes without reading secrets.
|
mailboxes without reading secrets.
|
||||||
|
- **Mail profile self-service user:** additionally create, edit, deactivate,
|
||||||
|
and manage credentials only for the current account's own user-scoped
|
||||||
|
profiles, subject to the effective Mail policy.
|
||||||
- **Mail profile administrator:** additionally create/update profiles and
|
- **Mail profile administrator:** additionally create/update profiles and
|
||||||
create/replace encrypted credentials.
|
create/replace encrypted credentials across tenant-owned scopes.
|
||||||
|
|
||||||
The specific permissions are `mail:profile:read`, `mail:profile:use`,
|
The specific permissions are `mail:profile:read`, `mail:profile:use`,
|
||||||
`mail:profile:test`, `mail:mailbox:read`, `mail:profile:write`, and
|
`mail:profile:test`, `mail:mailbox:read`, `mail:profile:write_own`,
|
||||||
`mail:secret:manage`. System-scoped definitions use the corresponding system
|
`mail:secret:manage_own`, `mail:profile:write`, and `mail:secret:manage`.
|
||||||
settings authority. Keep secret management separate when an institution wants
|
The `_own` permissions are enforced against the authenticated membership id and
|
||||||
profile metadata administrators not to know or replace credentials.
|
never authorize a tenant, group, campaign, system, or another user's profile.
|
||||||
|
They also do not authorize profile-policy changes. System-scoped definitions
|
||||||
|
use the corresponding system settings authority. Keep secret management
|
||||||
|
separate when an institution wants profile metadata administrators not to know
|
||||||
|
or replace credentials.
|
||||||
|
|
||||||
|
That separation is fail-closed for transport rebinding: changing an SMTP or
|
||||||
|
IMAP host, port, or security mode while the profile retains a stored password
|
||||||
|
requires the matching secret-management permission. A credential-free profile
|
||||||
|
can be deactivated with profile-write authority alone; deactivation that
|
||||||
|
scrubs a stored password also requires secret-management authority and records
|
||||||
|
the deletion in the audit log.
|
||||||
|
|
||||||
### Create or change a profile
|
### Create or change a profile
|
||||||
|
|
||||||
|
The configured Help Center exposes **Create a custom Mail profile** only when
|
||||||
|
the current actor has broad or self-service profile-write authority and the
|
||||||
|
effective user-scope policy permits user profiles. It states the active SMTP/IMAP hostname
|
||||||
|
allow-list groups and deny rules, plus the actor's separate credential, test,
|
||||||
|
use, and approval requirements. The Settings task creates in the current
|
||||||
|
account's user scope. Grant `mail:profile:write_own` for self-service;
|
||||||
|
`mail:profile:write` remains broad profile administration authority.
|
||||||
|
|
||||||
1. Choose the narrowest suitable scope and a stable, descriptive name/slug.
|
1. Choose the narrowest suitable scope and a stable, descriptive name/slug.
|
||||||
2. Configure SMTP, optional IMAP, TLS mode, account identity, Sent-folder
|
2. Configure SMTP, optional IMAP, TLS mode, account identity, Sent-folder
|
||||||
behavior, and timeouts. Sender/envelope/recipient constraints belong to
|
behavior, and timeouts. Sender/envelope/recipient constraints belong to
|
||||||
@@ -328,11 +364,11 @@ Security invariants:
|
|||||||
Credential replacement/deletion emits canonical non-secret Mail audit events.
|
Credential replacement/deletion emits canonical non-secret Mail audit events.
|
||||||
Profile/policy create/update/deactivate currently emits change-feed evidence,
|
Profile/policy create/update/deactivate currently emits change-feed evidence,
|
||||||
but connection tests and the complete administrative lifecycle do not yet have
|
but connection tests and the complete administrative lifecycle do not yet have
|
||||||
equivalent canonical audit events. Consumer send/retry/reconciliation evidence
|
equivalent canonical audit events. Mail delivery commands emit request,
|
||||||
belongs primarily to the consuming module today. A Mail-owned restricted
|
terminal-outcome, reconciliation, and deliberate-resend events without
|
||||||
provider-attempt ledger with correlation, retention, and unknown-outcome
|
addresses or raw provider responses. MIME, envelope addresses, detailed
|
||||||
reconciliation remains part of the durable outbox work in
|
refusals, and reconciliation notes are encrypted and minimized after their
|
||||||
[`govoplan-mail#17`](https://git.add-ideas.de/add-ideas/govoplan-mail/issues/17).
|
retention deadline while hashes, counts, attempts, and decisions remain.
|
||||||
|
|
||||||
## Acceptance checklist
|
## Acceptance checklist
|
||||||
|
|
||||||
@@ -363,7 +399,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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/mail-webui",
|
"name": "@govoplan/mail-webui",
|
||||||
"version": "0.1.9",
|
"version": "0.1.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.9",
|
"@govoplan/core-webui": "^0.1.10",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-mail"
|
name = "govoplan-mail"
|
||||||
version = "0.1.9"
|
version = "0.1.10"
|
||||||
description = "GovOPlaN mail module with backend and WebUI integration."
|
description = "GovOPlaN mail module with backend and WebUI integration."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from email.utils import formatdate, make_msgid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.mail import NotificationMailDeliveryRequest
|
||||||
from govoplan_core.core.modules import ModuleContext
|
from govoplan_core.core.modules import ModuleContext
|
||||||
from govoplan_mail.backend.mail_profiles import (
|
from govoplan_mail.backend.mail_profiles import (
|
||||||
MailProfileError,
|
MailProfileError,
|
||||||
_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 +23,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 +114,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 +124,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 +181,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 +217,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 +286,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 +355,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 +394,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 +482,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:
|
||||||
@@ -309,6 +514,32 @@ class MailCampaignCapability:
|
|||||||
append_campaign_message_to_sent = staticmethod(append_campaign_message_to_sent)
|
append_campaign_message_to_sent = staticmethod(append_campaign_message_to_sent)
|
||||||
wait_for_rate_limit = staticmethod(wait_for_rate_limit)
|
wait_for_rate_limit = staticmethod(wait_for_rate_limit)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def submit_delivery_command(session: Session, **kwargs: Any) -> dict[str, object]:
|
||||||
|
from govoplan_mail.backend.delivery_outbox import submit_delivery_command
|
||||||
|
|
||||||
|
return submit_delivery_command(session, **kwargs)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delivery_command_summary(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
command_id: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
from govoplan_mail.backend.delivery_outbox import (
|
||||||
|
delivery_command_summary,
|
||||||
|
get_delivery_command,
|
||||||
|
)
|
||||||
|
|
||||||
|
return delivery_command_summary(
|
||||||
|
get_delivery_command(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
command_id=command_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def mock_mailbox():
|
def mock_mailbox():
|
||||||
from govoplan_mail.backend.dev import mock_mailbox
|
from govoplan_mail.backend.dev import mock_mailbox
|
||||||
@@ -319,3 +550,109 @@ class MailCampaignCapability:
|
|||||||
def campaign_capability(context: ModuleContext) -> MailCampaignCapability:
|
def campaign_capability(context: ModuleContext) -> MailCampaignCapability:
|
||||||
configure_runtime(settings=context.settings)
|
configure_runtime(settings=context.settings)
|
||||||
return MailCampaignCapability()
|
return MailCampaignCapability()
|
||||||
|
|
||||||
|
|
||||||
|
def delivery_outbox_capability(context: ModuleContext):
|
||||||
|
from govoplan_mail.backend.delivery_outbox import MailDeliveryOutboxCapability
|
||||||
|
|
||||||
|
configure_runtime(settings=context.settings)
|
||||||
|
return MailDeliveryOutboxCapability()
|
||||||
|
|
||||||
|
|
||||||
|
class MailNotificationDeliveryCapability:
|
||||||
|
"""Submit notification email to the Mail-owned durable outbox."""
|
||||||
|
|
||||||
|
def submit_notification_mail(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
request: NotificationMailDeliveryRequest,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
profile_id = str(request.mail_profile_id or "").strip()
|
||||||
|
from_address = str(request.from_address or "").strip()
|
||||||
|
if not profile_id or not from_address:
|
||||||
|
return {
|
||||||
|
"status": "paused",
|
||||||
|
"provider": "mail.notificationDelivery",
|
||||||
|
"error": (
|
||||||
|
"Notification email requires a Mail profile and sender "
|
||||||
|
"selected by tenant policy."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
transport = campaign_profile_delivery_summary(
|
||||||
|
session,
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
smtp_server_id=request.smtp_server_id,
|
||||||
|
smtp_credential_id=request.smtp_credential_id,
|
||||||
|
)
|
||||||
|
except MailProfileError:
|
||||||
|
return {
|
||||||
|
"status": "paused",
|
||||||
|
"provider": "mail.notificationDelivery",
|
||||||
|
"error": (
|
||||||
|
"The selected notification Mail profile is unavailable or "
|
||||||
|
"blocked by effective policy."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if not transport.get("smtp_available"):
|
||||||
|
return {
|
||||||
|
"status": "paused",
|
||||||
|
"provider": "mail.notificationDelivery",
|
||||||
|
"error": "The selected notification Mail profile has no usable SMTP transport.",
|
||||||
|
}
|
||||||
|
|
||||||
|
message = EmailMessage()
|
||||||
|
message["Date"] = formatdate(localtime=True)
|
||||||
|
message["Message-ID"] = make_msgid()
|
||||||
|
message["Subject"] = request.subject
|
||||||
|
message["From"] = from_address
|
||||||
|
message["To"] = request.recipient
|
||||||
|
message.set_content(request.body_text)
|
||||||
|
if request.body_html:
|
||||||
|
message.add_alternative(request.body_html, subtype="html")
|
||||||
|
|
||||||
|
from govoplan_mail.backend.delivery_outbox import submit_delivery_command
|
||||||
|
|
||||||
|
command = submit_delivery_command(
|
||||||
|
session,
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
command_type="notification",
|
||||||
|
source_module="notifications",
|
||||||
|
source_resource_type="notification",
|
||||||
|
source_resource_id=request.notification_id,
|
||||||
|
source_version_id=None,
|
||||||
|
idempotency_key=f"notification:{request.notification_id}",
|
||||||
|
profile_id=profile_id,
|
||||||
|
smtp_server_id=(
|
||||||
|
str(transport.get("smtp_server_id"))
|
||||||
|
if transport.get("smtp_server_id")
|
||||||
|
else request.smtp_server_id
|
||||||
|
),
|
||||||
|
smtp_credential_id=(
|
||||||
|
str(transport.get("smtp_credential_id"))
|
||||||
|
if transport.get("smtp_credential_id")
|
||||||
|
else request.smtp_credential_id
|
||||||
|
),
|
||||||
|
message_bytes=bytes(message),
|
||||||
|
envelope_from=from_address,
|
||||||
|
envelope_recipients=[request.recipient],
|
||||||
|
from_header=from_address,
|
||||||
|
expected_smtp_transport_revision=str(
|
||||||
|
transport["smtp_transport_revision"]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "accepted",
|
||||||
|
"provider": "mail.delivery_outbox",
|
||||||
|
"external_message_id": command["id"],
|
||||||
|
"delivery_status": command["status"],
|
||||||
|
"duplicate": bool(command.get("duplicate")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def notification_delivery_capability(
|
||||||
|
context: ModuleContext,
|
||||||
|
) -> MailNotificationDeliveryCapability:
|
||||||
|
configure_runtime(settings=context.settings)
|
||||||
|
return MailNotificationDeliveryCapability()
|
||||||
|
|||||||
@@ -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__ = (
|
||||||
@@ -100,3 +152,137 @@ class MailMailboxMessageIndex(Base, TimestampMixin):
|
|||||||
body_preview: Mapped[str | None] = mapped_column(Text, nullable=True)
|
body_preview: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
attachment_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
attachment_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
indexed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
indexed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class MailDeliveryCommand(Base, TimestampMixin):
|
||||||
|
__tablename__ = "mail_delivery_commands"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"command_type",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_mail_delivery_commands_idempotency",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_mail_delivery_commands_dispatch",
|
||||||
|
"status",
|
||||||
|
"next_attempt_at",
|
||||||
|
"created_at",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_mail_delivery_commands_source",
|
||||||
|
"tenant_id",
|
||||||
|
"source_module",
|
||||||
|
"source_resource_type",
|
||||||
|
"source_resource_id",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
command_type: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
|
||||||
|
source_module: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||||
|
source_resource_type: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||||
|
source_resource_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
source_version_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
idempotency_key: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
canonical_request_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
profile_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("mail_server_profiles.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
smtp_server_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||||
|
smtp_credential_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||||
|
expected_smtp_transport_revision: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
envelope_from_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
envelope_recipients_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
from_header_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
message_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
message_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
message_size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||||
|
recipient_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(40), nullable=False, default="pending", index=True)
|
||||||
|
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
next_attempt_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
claimed_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
effect_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
accepted_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
refused_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
refusal_summary: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
refusal_details_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
failure_code: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||||
|
failure_summary: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
created_by_user_id: Mapped[str | None] = mapped_column(
|
||||||
|
ForeignKey("access_users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
supersedes_command_id: Mapped[str | None] = mapped_column(
|
||||||
|
ForeignKey("mail_delivery_commands.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
|
payload_purged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class MailDeliveryAttempt(Base, TimestampMixin):
|
||||||
|
__tablename__ = "mail_delivery_attempts"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"command_id",
|
||||||
|
"attempt_number",
|
||||||
|
name="uq_mail_delivery_attempts_number",
|
||||||
|
),
|
||||||
|
Index("ix_mail_delivery_attempts_command_started", "command_id", "started_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
command_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("mail_delivery_commands.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
attempt_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
worker_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||||
|
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
effect_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
accepted_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
refused_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
outcome_code: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||||
|
diagnostic_summary: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class MailDeliveryReconciliation(Base, TimestampMixin):
|
||||||
|
__tablename__ = "mail_delivery_reconciliations"
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"ix_mail_delivery_reconciliations_command_created",
|
||||||
|
"command_id",
|
||||||
|
"created_at",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
command_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("mail_delivery_commands.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
decision: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||||
|
evidence_reference: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
note_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_by_user_id: Mapped[str | None] = mapped_column(
|
||||||
|
ForeignKey("access_users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|||||||
880
src/govoplan_mail/backend/delivery_outbox.py
Normal file
880
src/govoplan_mail/backend/delivery_outbox.py
Normal file
@@ -0,0 +1,880 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from collections import Counter
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import or_, select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.audit.logging import audit_event
|
||||||
|
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
|
||||||
|
from govoplan_mail.backend.capabilities import send_campaign_email_bytes
|
||||||
|
from govoplan_mail.backend.db.models import (
|
||||||
|
MailDeliveryAttempt,
|
||||||
|
MailDeliveryCommand,
|
||||||
|
MailDeliveryReconciliation,
|
||||||
|
)
|
||||||
|
from govoplan_mail.backend.mail_profiles import MailProfileError
|
||||||
|
from govoplan_mail.backend.sending.smtp import SmtpConfigurationError, SmtpSendError
|
||||||
|
|
||||||
|
|
||||||
|
DISPATCHABLE_STATUSES = frozenset({"pending", "temporary_failure", "reconciled_not_accepted"})
|
||||||
|
TERMINAL_STATUSES = frozenset(
|
||||||
|
{
|
||||||
|
"accepted",
|
||||||
|
"partially_refused",
|
||||||
|
"permanent_failure",
|
||||||
|
"outcome_unknown",
|
||||||
|
"reconciled_accepted",
|
||||||
|
"cancelled",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
NON_RETRYABLE_STATUSES = TERMINAL_STATUSES | frozenset({"claimed", "in_progress"})
|
||||||
|
DEFAULT_PAYLOAD_RETENTION_DAYS = 30
|
||||||
|
STALE_CLAIM_AFTER = timedelta(minutes=10)
|
||||||
|
|
||||||
|
|
||||||
|
class MailDeliveryError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MailDeliveryIdempotencyConflict(MailDeliveryError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MailDeliveryNotFound(MailDeliveryError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MailDeliveryStateError(MailDeliveryError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_text(value: object | None, *, limit: int = 500) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
candidate = " ".join(str(value).split())
|
||||||
|
return candidate[:limit] or None
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_hash(payload: dict[str, object]) -> str:
|
||||||
|
encoded = json.dumps(
|
||||||
|
payload,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
ensure_ascii=True,
|
||||||
|
).encode("utf-8")
|
||||||
|
return hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _encrypt_json(value: object) -> str:
|
||||||
|
encrypted = encrypt_secret(
|
||||||
|
json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
||||||
|
)
|
||||||
|
if not encrypted:
|
||||||
|
raise MailDeliveryError("Mail delivery evidence could not be encrypted")
|
||||||
|
return encrypted
|
||||||
|
|
||||||
|
|
||||||
|
def _decrypt_json(value: str | None) -> Any:
|
||||||
|
plaintext = decrypt_secret(value)
|
||||||
|
if plaintext is None:
|
||||||
|
return None
|
||||||
|
return json.loads(plaintext)
|
||||||
|
|
||||||
|
|
||||||
|
def _message_bytes(command: MailDeliveryCommand) -> bytes:
|
||||||
|
encoded = decrypt_secret(command.message_encrypted)
|
||||||
|
if encoded is None:
|
||||||
|
raise MailDeliveryStateError("Mail delivery payload is no longer available")
|
||||||
|
try:
|
||||||
|
message = base64.b64decode(encoded.encode("ascii"), validate=True)
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise MailDeliveryStateError("Mail delivery payload is invalid") from exc
|
||||||
|
if hashlib.sha256(message).hexdigest() != command.message_sha256:
|
||||||
|
raise MailDeliveryStateError("Mail delivery payload integrity check failed")
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
def _delivery_payload(
|
||||||
|
*,
|
||||||
|
command_type: str,
|
||||||
|
source_module: str,
|
||||||
|
source_resource_type: str,
|
||||||
|
source_resource_id: str | None,
|
||||||
|
source_version_id: str | None,
|
||||||
|
profile_id: str,
|
||||||
|
smtp_server_id: str | None,
|
||||||
|
smtp_credential_id: str | None,
|
||||||
|
expected_smtp_transport_revision: str,
|
||||||
|
envelope_from: str,
|
||||||
|
envelope_recipients: list[str],
|
||||||
|
from_header: str | None,
|
||||||
|
message_sha256: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"command_type": command_type,
|
||||||
|
"source_module": source_module,
|
||||||
|
"source_resource_type": source_resource_type,
|
||||||
|
"source_resource_id": source_resource_id,
|
||||||
|
"source_version_id": source_version_id,
|
||||||
|
"profile_id": profile_id,
|
||||||
|
"smtp_server_id": smtp_server_id,
|
||||||
|
"smtp_credential_id": smtp_credential_id,
|
||||||
|
"expected_smtp_transport_revision": expected_smtp_transport_revision,
|
||||||
|
"envelope_from": envelope_from,
|
||||||
|
"envelope_recipients": envelope_recipients,
|
||||||
|
"from_header": from_header,
|
||||||
|
"message_sha256": message_sha256,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def submit_delivery_command(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
command_type: str,
|
||||||
|
source_module: str,
|
||||||
|
source_resource_type: str,
|
||||||
|
source_resource_id: str | None,
|
||||||
|
source_version_id: str | None,
|
||||||
|
idempotency_key: str,
|
||||||
|
profile_id: str,
|
||||||
|
message_bytes: bytes,
|
||||||
|
envelope_from: str,
|
||||||
|
envelope_recipients: list[str],
|
||||||
|
from_header: str | None,
|
||||||
|
expected_smtp_transport_revision: str,
|
||||||
|
smtp_server_id: str | None = None,
|
||||||
|
smtp_credential_id: str | None = None,
|
||||||
|
created_by_user_id: str | None = None,
|
||||||
|
retention_days: int = DEFAULT_PAYLOAD_RETENTION_DAYS,
|
||||||
|
supersedes_command_id: str | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
clean_key = idempotency_key.strip()
|
||||||
|
clean_recipients = [str(value).strip() for value in envelope_recipients if str(value).strip()]
|
||||||
|
if not clean_key or len(clean_key) > 200:
|
||||||
|
raise MailDeliveryError("A bounded idempotency key is required")
|
||||||
|
if not clean_recipients:
|
||||||
|
raise MailDeliveryError("At least one envelope recipient is required")
|
||||||
|
if retention_days < 1:
|
||||||
|
raise MailDeliveryError("Mail payload retention must be at least one day")
|
||||||
|
digest = hashlib.sha256(message_bytes).hexdigest()
|
||||||
|
request_hash = _canonical_hash(
|
||||||
|
_delivery_payload(
|
||||||
|
command_type=command_type,
|
||||||
|
source_module=source_module,
|
||||||
|
source_resource_type=source_resource_type,
|
||||||
|
source_resource_id=source_resource_id,
|
||||||
|
source_version_id=source_version_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
smtp_server_id=smtp_server_id,
|
||||||
|
smtp_credential_id=smtp_credential_id,
|
||||||
|
expected_smtp_transport_revision=expected_smtp_transport_revision,
|
||||||
|
envelope_from=envelope_from,
|
||||||
|
envelope_recipients=clean_recipients,
|
||||||
|
from_header=from_header,
|
||||||
|
message_sha256=digest,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing = session.scalar(
|
||||||
|
select(MailDeliveryCommand).where(
|
||||||
|
MailDeliveryCommand.tenant_id == tenant_id,
|
||||||
|
MailDeliveryCommand.command_type == command_type,
|
||||||
|
MailDeliveryCommand.idempotency_key == clean_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
if existing.canonical_request_hash != request_hash:
|
||||||
|
raise MailDeliveryIdempotencyConflict(
|
||||||
|
"The idempotency key is already bound to a different mail command"
|
||||||
|
)
|
||||||
|
return delivery_command_summary(existing, duplicate=True)
|
||||||
|
|
||||||
|
now = utcnow()
|
||||||
|
command = MailDeliveryCommand(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
command_type=command_type,
|
||||||
|
source_module=source_module,
|
||||||
|
source_resource_type=source_resource_type,
|
||||||
|
source_resource_id=source_resource_id,
|
||||||
|
source_version_id=source_version_id,
|
||||||
|
idempotency_key=clean_key,
|
||||||
|
canonical_request_hash=request_hash,
|
||||||
|
profile_id=profile_id,
|
||||||
|
smtp_server_id=smtp_server_id,
|
||||||
|
smtp_credential_id=smtp_credential_id,
|
||||||
|
expected_smtp_transport_revision=expected_smtp_transport_revision,
|
||||||
|
envelope_from_encrypted=encrypt_secret(envelope_from),
|
||||||
|
envelope_recipients_encrypted=_encrypt_json(clean_recipients),
|
||||||
|
from_header_encrypted=encrypt_secret(from_header),
|
||||||
|
message_encrypted=encrypt_secret(base64.b64encode(message_bytes).decode("ascii")),
|
||||||
|
message_sha256=digest,
|
||||||
|
message_size_bytes=len(message_bytes),
|
||||||
|
recipient_count=len(clean_recipients),
|
||||||
|
status="pending",
|
||||||
|
next_attempt_at=now,
|
||||||
|
created_by_user_id=created_by_user_id,
|
||||||
|
supersedes_command_id=supersedes_command_id,
|
||||||
|
expires_at=now + timedelta(days=retention_days),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with session.begin_nested():
|
||||||
|
session.add(command)
|
||||||
|
session.flush()
|
||||||
|
except IntegrityError:
|
||||||
|
existing = session.scalar(
|
||||||
|
select(MailDeliveryCommand).where(
|
||||||
|
MailDeliveryCommand.tenant_id == tenant_id,
|
||||||
|
MailDeliveryCommand.command_type == command_type,
|
||||||
|
MailDeliveryCommand.idempotency_key == clean_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing is None or existing.canonical_request_hash != request_hash:
|
||||||
|
raise MailDeliveryIdempotencyConflict(
|
||||||
|
"The idempotency key is already bound to a different mail command"
|
||||||
|
) from None
|
||||||
|
return delivery_command_summary(existing, duplicate=True)
|
||||||
|
|
||||||
|
audit_event(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=created_by_user_id,
|
||||||
|
action="mail.delivery_requested",
|
||||||
|
object_type="mail_delivery_command",
|
||||||
|
object_id=command.id,
|
||||||
|
details={
|
||||||
|
"command_type": command_type,
|
||||||
|
"source_module": source_module,
|
||||||
|
"source_resource_type": source_resource_type,
|
||||||
|
"source_resource_id": source_resource_id,
|
||||||
|
"recipient_count": len(clean_recipients),
|
||||||
|
"message_sha256": digest,
|
||||||
|
"supersedes_command_id": supersedes_command_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return delivery_command_summary(command)
|
||||||
|
|
||||||
|
|
||||||
|
def get_delivery_command(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
command_id: str,
|
||||||
|
) -> MailDeliveryCommand:
|
||||||
|
command = session.get(MailDeliveryCommand, command_id)
|
||||||
|
if command is None or command.tenant_id != tenant_id:
|
||||||
|
raise MailDeliveryNotFound("Mail delivery command not found")
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
def delivery_command_summary(
|
||||||
|
command: MailDeliveryCommand,
|
||||||
|
*,
|
||||||
|
duplicate: bool = False,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": command.id,
|
||||||
|
"tenant_id": command.tenant_id,
|
||||||
|
"command_type": command.command_type,
|
||||||
|
"source_module": command.source_module,
|
||||||
|
"source_resource_type": command.source_resource_type,
|
||||||
|
"source_resource_id": command.source_resource_id,
|
||||||
|
"source_version_id": command.source_version_id,
|
||||||
|
"status": command.status,
|
||||||
|
"recipient_count": command.recipient_count,
|
||||||
|
"accepted_count": command.accepted_count,
|
||||||
|
"refused_count": command.refused_count,
|
||||||
|
"refusal_summary": dict(command.refusal_summary or {}),
|
||||||
|
"attempt_count": command.attempt_count,
|
||||||
|
"failure_code": command.failure_code,
|
||||||
|
"failure_summary": command.failure_summary,
|
||||||
|
"message_sha256": command.message_sha256,
|
||||||
|
"message_size_bytes": command.message_size_bytes,
|
||||||
|
"created_at": command.created_at,
|
||||||
|
"completed_at": command.completed_at,
|
||||||
|
"expires_at": command.expires_at,
|
||||||
|
"payload_purged_at": command.payload_purged_at,
|
||||||
|
"safe_to_retry": command.status in DISPATCHABLE_STATUSES,
|
||||||
|
"outcome_known": command.status
|
||||||
|
not in {"claimed", "in_progress", "outcome_unknown"},
|
||||||
|
"duplicate": duplicate,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def delivery_command_diagnostics(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
command_id: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
command = get_delivery_command(session, tenant_id=tenant_id, command_id=command_id)
|
||||||
|
attempts = session.scalars(
|
||||||
|
select(MailDeliveryAttempt)
|
||||||
|
.where(MailDeliveryAttempt.command_id == command.id)
|
||||||
|
.order_by(MailDeliveryAttempt.attempt_number)
|
||||||
|
).all()
|
||||||
|
reconciliations = session.scalars(
|
||||||
|
select(MailDeliveryReconciliation)
|
||||||
|
.where(MailDeliveryReconciliation.command_id == command.id)
|
||||||
|
.order_by(MailDeliveryReconciliation.created_at)
|
||||||
|
).all()
|
||||||
|
refusals = _decrypt_json(command.refusal_details_encrypted) or {}
|
||||||
|
return {
|
||||||
|
**delivery_command_summary(command),
|
||||||
|
"refused_recipients": refusals,
|
||||||
|
"attempts": [
|
||||||
|
{
|
||||||
|
"id": attempt.id,
|
||||||
|
"attempt_number": attempt.attempt_number,
|
||||||
|
"worker_id": attempt.worker_id,
|
||||||
|
"status": attempt.status,
|
||||||
|
"started_at": attempt.started_at,
|
||||||
|
"effect_started_at": attempt.effect_started_at,
|
||||||
|
"completed_at": attempt.completed_at,
|
||||||
|
"accepted_count": attempt.accepted_count,
|
||||||
|
"refused_count": attempt.refused_count,
|
||||||
|
"outcome_code": attempt.outcome_code,
|
||||||
|
"diagnostic_summary": attempt.diagnostic_summary,
|
||||||
|
}
|
||||||
|
for attempt in attempts
|
||||||
|
],
|
||||||
|
"reconciliations": [
|
||||||
|
{
|
||||||
|
"id": item.id,
|
||||||
|
"decision": item.decision,
|
||||||
|
"evidence_reference": item.evidence_reference,
|
||||||
|
"note": decrypt_secret(item.note_encrypted),
|
||||||
|
"created_by_user_id": item.created_by_user_id,
|
||||||
|
"created_at": item.created_at,
|
||||||
|
}
|
||||||
|
for item in reconciliations
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _current_attempt(
|
||||||
|
session: Session,
|
||||||
|
command: MailDeliveryCommand,
|
||||||
|
) -> MailDeliveryAttempt | None:
|
||||||
|
return session.scalar(
|
||||||
|
select(MailDeliveryAttempt)
|
||||||
|
.where(
|
||||||
|
MailDeliveryAttempt.command_id == command.id,
|
||||||
|
MailDeliveryAttempt.attempt_number == command.attempt_count,
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _recover_stale_commands(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
now: datetime,
|
||||||
|
tenant_id: str | None,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
cutoff = now - STALE_CLAIM_AFTER
|
||||||
|
clauses = [
|
||||||
|
MailDeliveryCommand.status.in_(("claimed", "in_progress")),
|
||||||
|
MailDeliveryCommand.claimed_at <= cutoff,
|
||||||
|
]
|
||||||
|
if tenant_id:
|
||||||
|
clauses.append(MailDeliveryCommand.tenant_id == tenant_id)
|
||||||
|
commands = session.scalars(
|
||||||
|
select(MailDeliveryCommand).where(*clauses).order_by(MailDeliveryCommand.claimed_at)
|
||||||
|
).all()
|
||||||
|
recovered = 0
|
||||||
|
unknown = 0
|
||||||
|
for command in commands:
|
||||||
|
attempt = _current_attempt(session, command)
|
||||||
|
effect_started = command.effect_started_at or (
|
||||||
|
attempt.effect_started_at if attempt else None
|
||||||
|
)
|
||||||
|
if effect_started is None:
|
||||||
|
command.status = "pending"
|
||||||
|
command.next_attempt_at = now
|
||||||
|
command.claimed_at = None
|
||||||
|
command.claimed_by = None
|
||||||
|
if attempt is not None:
|
||||||
|
attempt.status = "claim_abandoned"
|
||||||
|
attempt.completed_at = now
|
||||||
|
attempt.outcome_code = "worker_lost_before_effect"
|
||||||
|
recovered += 1
|
||||||
|
continue
|
||||||
|
command.status = "outcome_unknown"
|
||||||
|
command.completed_at = now
|
||||||
|
command.next_attempt_at = None
|
||||||
|
command.failure_code = "worker_lost_after_effect_start"
|
||||||
|
command.failure_summary = (
|
||||||
|
"Delivery outcome is unknown because the worker stopped after transmission began."
|
||||||
|
)
|
||||||
|
if attempt is not None:
|
||||||
|
attempt.status = "outcome_unknown"
|
||||||
|
attempt.completed_at = now
|
||||||
|
attempt.outcome_code = command.failure_code
|
||||||
|
attempt.diagnostic_summary = command.failure_summary
|
||||||
|
unknown += 1
|
||||||
|
if commands:
|
||||||
|
session.commit()
|
||||||
|
return recovered, unknown
|
||||||
|
|
||||||
|
|
||||||
|
def _claim_command(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
command_id: str,
|
||||||
|
now: datetime,
|
||||||
|
worker_id: str | None,
|
||||||
|
) -> tuple[MailDeliveryCommand, MailDeliveryAttempt] | None:
|
||||||
|
command = session.scalar(
|
||||||
|
select(MailDeliveryCommand)
|
||||||
|
.where(
|
||||||
|
MailDeliveryCommand.id == command_id,
|
||||||
|
MailDeliveryCommand.status.in_(DISPATCHABLE_STATUSES),
|
||||||
|
or_(
|
||||||
|
MailDeliveryCommand.next_attempt_at.is_(None),
|
||||||
|
MailDeliveryCommand.next_attempt_at <= now,
|
||||||
|
),
|
||||||
|
MailDeliveryCommand.payload_purged_at.is_(None),
|
||||||
|
)
|
||||||
|
.with_for_update(skip_locked=True)
|
||||||
|
)
|
||||||
|
if command is None:
|
||||||
|
session.rollback()
|
||||||
|
return None
|
||||||
|
command.attempt_count += 1
|
||||||
|
command.status = "claimed"
|
||||||
|
command.claimed_by = _bounded_text(worker_id, limit=255)
|
||||||
|
command.claimed_at = now
|
||||||
|
command.effect_started_at = None
|
||||||
|
command.next_attempt_at = None
|
||||||
|
command.failure_code = None
|
||||||
|
command.failure_summary = None
|
||||||
|
attempt = MailDeliveryAttempt(
|
||||||
|
command_id=command.id,
|
||||||
|
attempt_number=command.attempt_count,
|
||||||
|
worker_id=command.claimed_by,
|
||||||
|
status="claimed",
|
||||||
|
started_at=now,
|
||||||
|
)
|
||||||
|
session.add(attempt)
|
||||||
|
session.commit()
|
||||||
|
return command, attempt
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_effect_started(
|
||||||
|
session: Session,
|
||||||
|
command: MailDeliveryCommand,
|
||||||
|
attempt: MailDeliveryAttempt,
|
||||||
|
) -> None:
|
||||||
|
now = utcnow()
|
||||||
|
command.status = "in_progress"
|
||||||
|
command.effect_started_at = now
|
||||||
|
attempt.status = "in_progress"
|
||||||
|
attempt.effect_started_at = now
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _refusal_summary(refusals: dict[str, dict[str, int | str]]) -> dict[str, int]:
|
||||||
|
classifications = Counter(
|
||||||
|
str(item.get("classification") or "unknown")
|
||||||
|
for item in refusals.values()
|
||||||
|
)
|
||||||
|
return dict(sorted(classifications.items()))
|
||||||
|
|
||||||
|
|
||||||
|
def _record_outcome(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
command: MailDeliveryCommand,
|
||||||
|
attempt: MailDeliveryAttempt,
|
||||||
|
status: str,
|
||||||
|
accepted_count: int = 0,
|
||||||
|
refusals: dict[str, dict[str, int | str]] | None = None,
|
||||||
|
failure_code: str | None = None,
|
||||||
|
failure_summary: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
now = utcnow()
|
||||||
|
refusal_map = refusals or {}
|
||||||
|
command.status = status
|
||||||
|
command.accepted_count = accepted_count
|
||||||
|
command.refused_count = len(refusal_map)
|
||||||
|
command.refusal_summary = _refusal_summary(refusal_map)
|
||||||
|
command.refusal_details_encrypted = (
|
||||||
|
_encrypt_json(refusal_map) if refusal_map else None
|
||||||
|
)
|
||||||
|
command.failure_code = failure_code
|
||||||
|
command.failure_summary = _bounded_text(failure_summary)
|
||||||
|
command.claimed_by = None
|
||||||
|
command.claimed_at = None
|
||||||
|
command.next_attempt_at = (
|
||||||
|
now + timedelta(minutes=min(60, 2 ** min(command.attempt_count, 5)))
|
||||||
|
if status == "temporary_failure"
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if status != "temporary_failure":
|
||||||
|
command.completed_at = now
|
||||||
|
attempt.status = status
|
||||||
|
attempt.completed_at = now
|
||||||
|
attempt.accepted_count = accepted_count
|
||||||
|
attempt.refused_count = len(refusal_map)
|
||||||
|
attempt.outcome_code = failure_code or status
|
||||||
|
attempt.diagnostic_summary = _bounded_text(failure_summary)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
audit_event(
|
||||||
|
session,
|
||||||
|
tenant_id=command.tenant_id,
|
||||||
|
user_id=command.created_by_user_id,
|
||||||
|
action="mail.delivery_completed",
|
||||||
|
object_type="mail_delivery_command",
|
||||||
|
object_id=command.id,
|
||||||
|
details={
|
||||||
|
"status": status,
|
||||||
|
"attempt_number": attempt.attempt_number,
|
||||||
|
"accepted_count": accepted_count,
|
||||||
|
"refused_count": len(refusal_map),
|
||||||
|
"refusal_summary": command.refusal_summary,
|
||||||
|
"failure_code": failure_code,
|
||||||
|
"source_module": command.source_module,
|
||||||
|
"source_resource_id": command.source_resource_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _process_claimed(
|
||||||
|
session: Session,
|
||||||
|
command: MailDeliveryCommand,
|
||||||
|
attempt: MailDeliveryAttempt,
|
||||||
|
) -> str:
|
||||||
|
try:
|
||||||
|
message = _message_bytes(command)
|
||||||
|
envelope_from = decrypt_secret(command.envelope_from_encrypted)
|
||||||
|
recipients = _decrypt_json(command.envelope_recipients_encrypted)
|
||||||
|
from_header = decrypt_secret(command.from_header_encrypted)
|
||||||
|
if not envelope_from or not isinstance(recipients, list) or not recipients:
|
||||||
|
raise MailDeliveryStateError("Mail delivery envelope is unavailable")
|
||||||
|
except MailDeliveryStateError as exc:
|
||||||
|
_record_outcome(
|
||||||
|
session,
|
||||||
|
command=command,
|
||||||
|
attempt=attempt,
|
||||||
|
status="permanent_failure",
|
||||||
|
failure_code="payload_unavailable",
|
||||||
|
failure_summary=str(exc),
|
||||||
|
)
|
||||||
|
return "permanent_failure"
|
||||||
|
|
||||||
|
_mark_effect_started(session, command, attempt)
|
||||||
|
try:
|
||||||
|
result = send_campaign_email_bytes(
|
||||||
|
session,
|
||||||
|
tenant_id=command.tenant_id,
|
||||||
|
campaign_id=str(command.source_resource_id or ""),
|
||||||
|
profile_id=command.profile_id,
|
||||||
|
message_bytes=message,
|
||||||
|
envelope_from=envelope_from,
|
||||||
|
envelope_recipients=[str(item) for item in recipients],
|
||||||
|
from_header=from_header,
|
||||||
|
expected_smtp_transport_revision=command.expected_smtp_transport_revision,
|
||||||
|
smtp_server_id=command.smtp_server_id,
|
||||||
|
smtp_credential_id=command.smtp_credential_id,
|
||||||
|
)
|
||||||
|
except SmtpSendError as exc:
|
||||||
|
if exc.outcome_unknown:
|
||||||
|
status = "outcome_unknown"
|
||||||
|
code = "smtp_outcome_unknown"
|
||||||
|
elif exc.temporary:
|
||||||
|
status = "temporary_failure"
|
||||||
|
code = "smtp_temporary_failure"
|
||||||
|
else:
|
||||||
|
status = "permanent_failure"
|
||||||
|
code = "smtp_permanent_failure"
|
||||||
|
_record_outcome(
|
||||||
|
session,
|
||||||
|
command=command,
|
||||||
|
attempt=attempt,
|
||||||
|
status=status,
|
||||||
|
failure_code=code,
|
||||||
|
failure_summary=str(exc),
|
||||||
|
)
|
||||||
|
return status
|
||||||
|
except (MailProfileError, SmtpConfigurationError) as exc:
|
||||||
|
_record_outcome(
|
||||||
|
session,
|
||||||
|
command=command,
|
||||||
|
attempt=attempt,
|
||||||
|
status="permanent_failure",
|
||||||
|
failure_code="authorization_or_configuration_changed",
|
||||||
|
failure_summary=str(exc),
|
||||||
|
)
|
||||||
|
return "permanent_failure"
|
||||||
|
except Exception:
|
||||||
|
_record_outcome(
|
||||||
|
session,
|
||||||
|
command=command,
|
||||||
|
attempt=attempt,
|
||||||
|
status="outcome_unknown",
|
||||||
|
failure_code="unexpected_error_after_effect_start",
|
||||||
|
failure_summary="Mail delivery outcome is unknown after transmission began.",
|
||||||
|
)
|
||||||
|
return "outcome_unknown"
|
||||||
|
|
||||||
|
refusals = dict(result.refused_recipients)
|
||||||
|
accepted_count = result.accepted_count
|
||||||
|
if not refusals:
|
||||||
|
status = "accepted"
|
||||||
|
elif accepted_count > 0:
|
||||||
|
status = "partially_refused"
|
||||||
|
elif all(
|
||||||
|
item.get("classification") == "temporary" for item in refusals.values()
|
||||||
|
):
|
||||||
|
status = "temporary_failure"
|
||||||
|
elif any(
|
||||||
|
item.get("classification") == "unknown" for item in refusals.values()
|
||||||
|
):
|
||||||
|
status = "outcome_unknown"
|
||||||
|
else:
|
||||||
|
status = "permanent_failure"
|
||||||
|
_record_outcome(
|
||||||
|
session,
|
||||||
|
command=command,
|
||||||
|
attempt=attempt,
|
||||||
|
status=status,
|
||||||
|
accepted_count=accepted_count,
|
||||||
|
refusals=refusals,
|
||||||
|
failure_code=None if status == "accepted" else f"smtp_{status}",
|
||||||
|
failure_summary=None if status == "accepted" else "One or more recipients were refused.",
|
||||||
|
)
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch_due(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
limit: int = 25,
|
||||||
|
worker_id: str | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
bounded_limit = max(1, min(int(limit), 100))
|
||||||
|
now = utcnow()
|
||||||
|
recovered, recovered_unknown = _recover_stale_commands(
|
||||||
|
session,
|
||||||
|
now=now,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
)
|
||||||
|
clauses = [
|
||||||
|
MailDeliveryCommand.status.in_(DISPATCHABLE_STATUSES),
|
||||||
|
MailDeliveryCommand.payload_purged_at.is_(None),
|
||||||
|
or_(
|
||||||
|
MailDeliveryCommand.next_attempt_at.is_(None),
|
||||||
|
MailDeliveryCommand.next_attempt_at <= now,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
if tenant_id:
|
||||||
|
clauses.append(MailDeliveryCommand.tenant_id == tenant_id)
|
||||||
|
command_ids = list(
|
||||||
|
session.scalars(
|
||||||
|
select(MailDeliveryCommand.id)
|
||||||
|
.where(*clauses)
|
||||||
|
.order_by(MailDeliveryCommand.next_attempt_at, MailDeliveryCommand.created_at)
|
||||||
|
.limit(bounded_limit)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
counters: Counter[str] = Counter()
|
||||||
|
processed_ids: list[str] = []
|
||||||
|
for command_id in command_ids:
|
||||||
|
claimed = _claim_command(
|
||||||
|
session,
|
||||||
|
command_id=command_id,
|
||||||
|
now=utcnow(),
|
||||||
|
worker_id=worker_id,
|
||||||
|
)
|
||||||
|
if claimed is None:
|
||||||
|
continue
|
||||||
|
command, attempt = claimed
|
||||||
|
outcome = _process_claimed(session, command, attempt)
|
||||||
|
counters[outcome] += 1
|
||||||
|
processed_ids.append(command.id)
|
||||||
|
return {
|
||||||
|
"selected": len(processed_ids),
|
||||||
|
"accepted": counters["accepted"],
|
||||||
|
"partially_refused": counters["partially_refused"],
|
||||||
|
"retrying": counters["temporary_failure"],
|
||||||
|
"failed": counters["permanent_failure"],
|
||||||
|
"outcome_unknown": counters["outcome_unknown"] + recovered_unknown,
|
||||||
|
"recovered_before_effect": recovered,
|
||||||
|
"command_ids": processed_ids,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_delivery_command(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
command_id: str,
|
||||||
|
decision: str,
|
||||||
|
evidence_reference: str,
|
||||||
|
note: str | None,
|
||||||
|
user_id: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
command = get_delivery_command(session, tenant_id=tenant_id, command_id=command_id)
|
||||||
|
if command.status not in {"outcome_unknown", "in_progress"}:
|
||||||
|
raise MailDeliveryStateError(
|
||||||
|
"Only a delivery with an unknown outcome can be reconciled"
|
||||||
|
)
|
||||||
|
clean_decision = decision.strip().casefold()
|
||||||
|
if clean_decision not in {"accepted", "not_accepted"}:
|
||||||
|
raise MailDeliveryStateError(
|
||||||
|
"Reconciliation decision must be accepted or not_accepted"
|
||||||
|
)
|
||||||
|
clean_evidence = _bounded_text(evidence_reference)
|
||||||
|
if not clean_evidence:
|
||||||
|
raise MailDeliveryStateError("An evidence reference is required")
|
||||||
|
item = MailDeliveryReconciliation(
|
||||||
|
command_id=command.id,
|
||||||
|
decision=clean_decision,
|
||||||
|
evidence_reference=clean_evidence,
|
||||||
|
note_encrypted=encrypt_secret(note),
|
||||||
|
created_by_user_id=user_id,
|
||||||
|
)
|
||||||
|
session.add(item)
|
||||||
|
command.status = (
|
||||||
|
"reconciled_accepted"
|
||||||
|
if clean_decision == "accepted"
|
||||||
|
else "reconciled_not_accepted"
|
||||||
|
)
|
||||||
|
command.completed_at = utcnow() if clean_decision == "accepted" else None
|
||||||
|
command.next_attempt_at = None
|
||||||
|
command.failure_code = f"reconciled_{clean_decision}"
|
||||||
|
command.failure_summary = "Delivery outcome was reconciled from external evidence."
|
||||||
|
audit_event(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
action="mail.delivery_reconciled",
|
||||||
|
object_type="mail_delivery_command",
|
||||||
|
object_id=command.id,
|
||||||
|
details={
|
||||||
|
"decision": clean_decision,
|
||||||
|
"evidence_reference": clean_evidence,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return delivery_command_summary(command)
|
||||||
|
|
||||||
|
|
||||||
|
def resend_delivery_command(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
command_id: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
user_id: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
command = get_delivery_command(session, tenant_id=tenant_id, command_id=command_id)
|
||||||
|
if command.status not in {
|
||||||
|
"outcome_unknown",
|
||||||
|
"reconciled_not_accepted",
|
||||||
|
"permanent_failure",
|
||||||
|
"partially_refused",
|
||||||
|
}:
|
||||||
|
raise MailDeliveryStateError(
|
||||||
|
"A deliberate resend is only available after a terminal or reconciled failure"
|
||||||
|
)
|
||||||
|
if command.payload_purged_at is not None:
|
||||||
|
raise MailDeliveryStateError("The retained delivery payload is no longer available")
|
||||||
|
message = _message_bytes(command)
|
||||||
|
recipients = _decrypt_json(command.envelope_recipients_encrypted)
|
||||||
|
envelope_from = decrypt_secret(command.envelope_from_encrypted)
|
||||||
|
if not envelope_from or not isinstance(recipients, list):
|
||||||
|
raise MailDeliveryStateError("The retained delivery envelope is unavailable")
|
||||||
|
result = submit_delivery_command(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
command_type=command.command_type,
|
||||||
|
source_module=command.source_module,
|
||||||
|
source_resource_type=command.source_resource_type,
|
||||||
|
source_resource_id=command.source_resource_id,
|
||||||
|
source_version_id=command.source_version_id,
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
profile_id=command.profile_id,
|
||||||
|
message_bytes=message,
|
||||||
|
envelope_from=envelope_from,
|
||||||
|
envelope_recipients=[str(item) for item in recipients],
|
||||||
|
from_header=decrypt_secret(command.from_header_encrypted),
|
||||||
|
expected_smtp_transport_revision=command.expected_smtp_transport_revision,
|
||||||
|
smtp_server_id=command.smtp_server_id,
|
||||||
|
smtp_credential_id=command.smtp_credential_id,
|
||||||
|
created_by_user_id=user_id,
|
||||||
|
retention_days=max(
|
||||||
|
1,
|
||||||
|
(command.expires_at - utcnow()).days,
|
||||||
|
),
|
||||||
|
supersedes_command_id=command.id,
|
||||||
|
)
|
||||||
|
audit_event(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
action="mail.delivery_resend_requested",
|
||||||
|
object_type="mail_delivery_command",
|
||||||
|
object_id=str(result["id"]),
|
||||||
|
details={"supersedes_command_id": command.id},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def purge_expired(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
limit: int = 250,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
now = utcnow()
|
||||||
|
commands = session.scalars(
|
||||||
|
select(MailDeliveryCommand)
|
||||||
|
.where(
|
||||||
|
MailDeliveryCommand.expires_at <= now,
|
||||||
|
MailDeliveryCommand.payload_purged_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(MailDeliveryCommand.expires_at)
|
||||||
|
.limit(max(1, min(int(limit), 1000)))
|
||||||
|
).all()
|
||||||
|
for command in commands:
|
||||||
|
command.envelope_from_encrypted = None
|
||||||
|
command.envelope_recipients_encrypted = None
|
||||||
|
command.from_header_encrypted = None
|
||||||
|
command.message_encrypted = None
|
||||||
|
command.refusal_details_encrypted = None
|
||||||
|
command.payload_purged_at = now
|
||||||
|
reconciliations = session.scalars(
|
||||||
|
select(MailDeliveryReconciliation)
|
||||||
|
.join(
|
||||||
|
MailDeliveryCommand,
|
||||||
|
MailDeliveryCommand.id == MailDeliveryReconciliation.command_id,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
MailDeliveryCommand.payload_purged_at == now,
|
||||||
|
MailDeliveryReconciliation.note_encrypted.is_not(None),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for reconciliation in reconciliations:
|
||||||
|
reconciliation.note_encrypted = None
|
||||||
|
session.commit()
|
||||||
|
return {"purged": len(commands)}
|
||||||
|
|
||||||
|
|
||||||
|
class MailDeliveryOutboxCapability:
|
||||||
|
dispatch_due = staticmethod(dispatch_due)
|
||||||
|
purge_expired = staticmethod(purge_expired)
|
||||||
@@ -4,15 +4,95 @@ from typing import Any
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.core.modules import DocumentationContext, DocumentationLink, DocumentationTopic
|
from govoplan_core.core.modules import (
|
||||||
from govoplan_mail.backend.mail_profiles import MailProfileError, effective_mail_profile_policy_for_scope
|
DocumentationCondition,
|
||||||
|
DocumentationConfigurationDecision,
|
||||||
|
DocumentationContext,
|
||||||
|
DocumentationLink,
|
||||||
|
DocumentationTopic,
|
||||||
|
)
|
||||||
|
from govoplan_mail.backend.mail_profiles import (
|
||||||
|
EffectiveMailProfilePolicy,
|
||||||
|
MailProfileError,
|
||||||
|
effective_mail_profile_policy_for_scope,
|
||||||
|
)
|
||||||
|
|
||||||
MAIL_POLICY_DOC_SCOPES = ("mail:profile:read", "admin:policies:read", "system:settings:read")
|
MAIL_POLICY_DOC_SCOPES = ("mail:profile:read", "admin:policies:read", "system:settings:read")
|
||||||
|
MAIL_PROFILE_READ_SCOPE = "mail:profile:read"
|
||||||
|
MAIL_PROFILE_WRITE_SCOPE = "mail:profile:write"
|
||||||
|
MAIL_PROFILE_WRITE_OWN_SCOPE = "mail:profile:write_own"
|
||||||
|
# RBAC permission identifiers; neither value is a stored credential.
|
||||||
|
MAIL_SECRET_MANAGE_SCOPE = "mail:secret:manage" # noqa: S105 # nosec B105
|
||||||
|
MAIL_SECRET_MANAGE_OWN_SCOPE = "mail:secret:manage_own" # noqa: S105 # nosec B105
|
||||||
|
MAIL_PROFILE_TEST_SCOPE = "mail:profile:test"
|
||||||
|
MAIL_PROFILE_USE_SCOPE = "mail:profile:use"
|
||||||
|
_HOST_POLICY_FIELDS = (("SMTP", "smtp_hosts"), ("IMAP", "imap_hosts"))
|
||||||
|
|
||||||
|
|
||||||
def documentation_topics(context: DocumentationContext) -> tuple[DocumentationTopic, ...]:
|
def documentation_topics(context: DocumentationContext) -> tuple[DocumentationTopic, ...]:
|
||||||
topic = _tenant_mail_policy_topic(context)
|
topics = [
|
||||||
return (topic,) if topic is not None else ()
|
topic
|
||||||
|
for topic in (
|
||||||
|
_tenant_mail_policy_topic(context),
|
||||||
|
_custom_mail_profile_topic(context),
|
||||||
|
)
|
||||||
|
if topic is not None
|
||||||
|
]
|
||||||
|
return tuple(topics)
|
||||||
|
|
||||||
|
|
||||||
|
def documentation_configuration_states(
|
||||||
|
context: DocumentationContext,
|
||||||
|
keys: tuple[str, ...],
|
||||||
|
) -> dict[str, DocumentationConfigurationDecision]:
|
||||||
|
if "mail_profile_policy" not in keys:
|
||||||
|
return {}
|
||||||
|
principal = context.principal
|
||||||
|
tenant_id = str(getattr(principal, "tenant_id", "") or "")
|
||||||
|
session = context.session
|
||||||
|
if not tenant_id or not isinstance(session, Session):
|
||||||
|
return {
|
||||||
|
"mail_profile_policy": DocumentationConfigurationDecision(
|
||||||
|
key="mail_profile_policy",
|
||||||
|
state="unavailable",
|
||||||
|
reason="The effective tenant policy cannot be evaluated in this context.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
policy = effective_mail_profile_policy_for_scope(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
scope_type="tenant",
|
||||||
|
)
|
||||||
|
except MailProfileError:
|
||||||
|
return {
|
||||||
|
"mail_profile_policy": DocumentationConfigurationDecision(
|
||||||
|
key="mail_profile_policy",
|
||||||
|
state="unavailable",
|
||||||
|
reason="The effective tenant policy could not be evaluated.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
tenant_sources = [
|
||||||
|
source
|
||||||
|
for source in policy.source_policies
|
||||||
|
if source.get("scope_type") == "tenant"
|
||||||
|
]
|
||||||
|
explicitly_configured = any(
|
||||||
|
bool(source.get("applied_fields"))
|
||||||
|
for source in tenant_sources
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"mail_profile_policy": DocumentationConfigurationDecision(
|
||||||
|
key="mail_profile_policy",
|
||||||
|
state="enabled" if explicitly_configured else "inherited",
|
||||||
|
source="tenant" if explicitly_configured else "system default",
|
||||||
|
reason=(
|
||||||
|
"A tenant policy is configured."
|
||||||
|
if explicitly_configured
|
||||||
|
else "The tenant uses the inherited system policy."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _tenant_mail_policy_topic(context: DocumentationContext) -> DocumentationTopic | None:
|
def _tenant_mail_policy_topic(context: DocumentationContext) -> DocumentationTopic | None:
|
||||||
@@ -29,17 +109,26 @@ def _tenant_mail_policy_topic(context: DocumentationContext) -> DocumentationTop
|
|||||||
try:
|
try:
|
||||||
policy = effective_mail_profile_policy_for_scope(session, tenant_id=tenant_id, scope_type="tenant")
|
policy = effective_mail_profile_policy_for_scope(session, tenant_id=tenant_id, scope_type="tenant")
|
||||||
except MailProfileError as exc:
|
except MailProfileError as exc:
|
||||||
|
user_documentation = context.documentation_type == "user"
|
||||||
return DocumentationTopic(
|
return DocumentationTopic(
|
||||||
id="mail.tenant-profile-policy-unavailable",
|
id="mail.tenant-profile-policy-unavailable",
|
||||||
title="Mail server policy could not be evaluated",
|
title="Mail server policy could not be evaluated",
|
||||||
summary="Mail profile documentation is installed, but the tenant-level effective policy could not be read for this request.",
|
summary="Mail profile documentation is installed, but the tenant-level effective policy could not be read for this request.",
|
||||||
body=str(exc),
|
body=(
|
||||||
|
"The current Mail policy could not be loaded. Try again or ask a Mail administrator for help."
|
||||||
|
if user_documentation
|
||||||
|
else str(exc)
|
||||||
|
),
|
||||||
layer="available",
|
layer="available",
|
||||||
documentation_types=(context.documentation_type,),
|
documentation_types=(context.documentation_type,),
|
||||||
source_module_id="mail",
|
source_module_id="mail",
|
||||||
order=41,
|
order=41,
|
||||||
links=(_mail_policy_api_link(),),
|
links=(
|
||||||
metadata={"error_type": type(exc).__name__},
|
(DocumentationLink(label="Public mail help", href="https://govplan.add-ideas.de/modules/mail", kind="public"),)
|
||||||
|
if user_documentation
|
||||||
|
else (_mail_policy_api_link(),)
|
||||||
|
),
|
||||||
|
metadata={} if user_documentation else {"error_type": type(exc).__name__},
|
||||||
)
|
)
|
||||||
|
|
||||||
effective = policy.as_dict()
|
effective = policy.as_dict()
|
||||||
@@ -90,6 +179,243 @@ def _tenant_mail_policy_topic(context: DocumentationContext) -> DocumentationTop
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _custom_mail_profile_topic(context: DocumentationContext) -> DocumentationTopic | None:
|
||||||
|
if context.documentation_type != "user":
|
||||||
|
return None
|
||||||
|
principal = context.principal
|
||||||
|
if not _has_any_scope(principal, (MAIL_PROFILE_WRITE_SCOPE, MAIL_PROFILE_WRITE_OWN_SCOPE)):
|
||||||
|
return None
|
||||||
|
if not _has_all_scopes(principal, (MAIL_PROFILE_READ_SCOPE,)):
|
||||||
|
return None
|
||||||
|
tenant_id = str(getattr(principal, "tenant_id", "") or "")
|
||||||
|
user_id = str(getattr(getattr(principal, "user", None), "id", "") or "")
|
||||||
|
session = context.session
|
||||||
|
if not tenant_id or not user_id or not isinstance(session, Session):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
policy = effective_mail_profile_policy_for_scope(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
scope_type="user",
|
||||||
|
scope_id=user_id,
|
||||||
|
)
|
||||||
|
except MailProfileError:
|
||||||
|
# A user-facing runtime topic must not turn internal policy resolution
|
||||||
|
# details into documentation output. The task simply remains absent
|
||||||
|
# until its effective policy can be proven.
|
||||||
|
return None
|
||||||
|
if not policy.allow_user_profiles:
|
||||||
|
return None
|
||||||
|
|
||||||
|
can_manage_credentials = _has_any_scope(
|
||||||
|
principal,
|
||||||
|
(MAIL_SECRET_MANAGE_SCOPE, MAIL_SECRET_MANAGE_OWN_SCOPE),
|
||||||
|
)
|
||||||
|
can_test_profile = _has_all_scopes(principal, (MAIL_PROFILE_TEST_SCOPE, MAIL_PROFILE_USE_SCOPE))
|
||||||
|
can_use_profile = _has_any_scope(principal, (MAIL_PROFILE_USE_SCOPE,))
|
||||||
|
approval_required = bool(policy.allowed_profile_id_sets)
|
||||||
|
constraints = _host_policy_constraint_records(policy)
|
||||||
|
authority_lines = _custom_profile_authority_lines(
|
||||||
|
can_manage_credentials=can_manage_credentials,
|
||||||
|
can_test_profile=can_test_profile,
|
||||||
|
can_use_profile=can_use_profile,
|
||||||
|
approval_required=approval_required,
|
||||||
|
)
|
||||||
|
steps = _custom_profile_steps(
|
||||||
|
can_manage_credentials=can_manage_credentials,
|
||||||
|
can_test_profile=can_test_profile,
|
||||||
|
can_use_profile=can_use_profile,
|
||||||
|
approval_required=approval_required,
|
||||||
|
)
|
||||||
|
return DocumentationTopic(
|
||||||
|
id="mail.workflow.create-custom-profile",
|
||||||
|
title="Create a custom Mail profile",
|
||||||
|
summary=(
|
||||||
|
"Create a reusable profile in the current account's user-scoped Settings view, "
|
||||||
|
"within the active SMTP and IMAP hostname policy."
|
||||||
|
),
|
||||||
|
body="\n".join(authority_lines),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("user",),
|
||||||
|
audience=("mail_profile_author", "campaign_manager"),
|
||||||
|
order=41,
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("mail",),
|
||||||
|
required_scopes=(MAIL_PROFILE_READ_SCOPE,),
|
||||||
|
any_scopes=(MAIL_PROFILE_WRITE_SCOPE, MAIL_PROFILE_WRITE_OWN_SCOPE),
|
||||||
|
configuration_keys=("mail_profile_policy",),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(label="My Mail profiles", href="/settings?section=mail-profiles", kind="runtime"),
|
||||||
|
DocumentationLink(label="Public mail help", href="https://govplan.add-ideas.de/modules/mail", kind="public"),
|
||||||
|
),
|
||||||
|
related_modules=("campaigns",),
|
||||||
|
unlocks=("A custom Mail-owned transport definition that authorized tasks can reference after all policy checks pass.",),
|
||||||
|
configuration_keys=("mail_profile_policy",),
|
||||||
|
i18n_key="mail.topic.create_custom_profile",
|
||||||
|
source_module_id="mail",
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"route": "/settings?section=mail-profiles",
|
||||||
|
"screen": "My Mail profiles",
|
||||||
|
"help_contexts": ["mail.profiles", "app.settings"],
|
||||||
|
"prerequisites": [
|
||||||
|
"The Mail profile editor is available in Settings and opens in the current account's user scope.",
|
||||||
|
"The effective Mail policy permits user-scoped profiles.",
|
||||||
|
],
|
||||||
|
"steps": list(steps),
|
||||||
|
"outcome": "A user-scoped custom Mail profile is saved without copying its credentials into a consuming module.",
|
||||||
|
"current_configuration": list(authority_lines),
|
||||||
|
"constraints": list(constraints),
|
||||||
|
"verification": _custom_profile_verification(
|
||||||
|
can_test_profile=can_test_profile,
|
||||||
|
can_use_profile=can_use_profile,
|
||||||
|
approval_required=approval_required,
|
||||||
|
),
|
||||||
|
"can_manage_credentials": can_manage_credentials,
|
||||||
|
"can_test_profile": can_test_profile,
|
||||||
|
"can_use_profile": can_use_profile,
|
||||||
|
"approval_required_before_use": approval_required,
|
||||||
|
"related_topic_ids": [
|
||||||
|
"mail.workflow.choose-and-test-profile",
|
||||||
|
"mail.profile-ownership-and-consumers",
|
||||||
|
"mail.profiles-and-policy",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _host_policy_constraint_records(policy: EffectiveMailProfilePolicy) -> tuple[dict[str, Any], ...]:
|
||||||
|
constraints: list[dict[str, Any]] = []
|
||||||
|
for label, key in _HOST_POLICY_FIELDS:
|
||||||
|
prefix = label.casefold()
|
||||||
|
denied = _display_patterns(policy.blacklist_patterns.get(key, []))
|
||||||
|
constraints.append({
|
||||||
|
"id": f"{prefix}-host-deny",
|
||||||
|
"label": f"{label} denied hosts",
|
||||||
|
"description": (
|
||||||
|
"Deny rules are checked first. The hostname must not match any listed pattern."
|
||||||
|
if denied
|
||||||
|
else "Deny rules are checked first. No hostname deny pattern is active."
|
||||||
|
),
|
||||||
|
**({"values": list(denied)} if denied else {}),
|
||||||
|
})
|
||||||
|
allowed_groups = tuple(
|
||||||
|
group
|
||||||
|
for group in (_display_patterns(items) for items in policy.whitelist_groups.get(key, []))
|
||||||
|
if group
|
||||||
|
)
|
||||||
|
if not allowed_groups:
|
||||||
|
constraints.append({
|
||||||
|
"id": f"{prefix}-host-allow",
|
||||||
|
"label": f"{label} allowed hosts",
|
||||||
|
"description": "After deny checks, no hostname allow-list group is active.",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
for index, group in enumerate(allowed_groups, start=1):
|
||||||
|
constraints.append({
|
||||||
|
"id": f"{prefix}-host-allow-{index}",
|
||||||
|
"label": f"{label} allowed hosts — group {index}",
|
||||||
|
"description": (
|
||||||
|
"After deny checks, the hostname must match at least one pattern in this group. "
|
||||||
|
"It must satisfy every active allow-list group shown for this protocol."
|
||||||
|
),
|
||||||
|
"values": list(group),
|
||||||
|
})
|
||||||
|
return tuple(constraints)
|
||||||
|
|
||||||
|
|
||||||
|
def _display_patterns(patterns: list[str]) -> tuple[str, ...]:
|
||||||
|
result: list[str] = []
|
||||||
|
for pattern in patterns:
|
||||||
|
value = str(pattern).strip()
|
||||||
|
if value and value not in result:
|
||||||
|
result.append(value)
|
||||||
|
return tuple(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _custom_profile_authority_lines(
|
||||||
|
*,
|
||||||
|
can_manage_credentials: bool,
|
||||||
|
can_test_profile: bool,
|
||||||
|
can_use_profile: bool,
|
||||||
|
approval_required: bool,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
credentials = (
|
||||||
|
"Credential authority: you may save or replace Mail-owned SMTP/IMAP passwords."
|
||||||
|
if can_manage_credentials
|
||||||
|
else "Credential authority: you may define the profile, but you cannot save or replace passwords; an actor with both profile-write and secret-management authority must do that when authentication requires one."
|
||||||
|
)
|
||||||
|
testing = (
|
||||||
|
"Test authority: you may run the profile's SMTP/IMAP connection tests after saving it as active."
|
||||||
|
if can_test_profile
|
||||||
|
else "Test authority: creating the profile does not let you run connection tests; ask an actor with both profile-test and profile-use authority to verify an active profile."
|
||||||
|
)
|
||||||
|
use = (
|
||||||
|
"Use authority: you may select the profile in an authorized task after its contextual policy checks pass."
|
||||||
|
if can_use_profile
|
||||||
|
else "Use authority: creating the profile does not let you select or use it; separate Mail profile use authority is required."
|
||||||
|
)
|
||||||
|
approval = (
|
||||||
|
"Approval: an approved-profile list is active. A newly generated profile reference must be approved before the profile can be selected or used."
|
||||||
|
if approval_required
|
||||||
|
else "Approval: no approved-profile list currently blocks a newly created profile, but each consuming task still rechecks its contextual policy."
|
||||||
|
)
|
||||||
|
return credentials, testing, use, approval
|
||||||
|
|
||||||
|
|
||||||
|
def _custom_profile_steps(
|
||||||
|
*,
|
||||||
|
can_manage_credentials: bool,
|
||||||
|
can_test_profile: bool,
|
||||||
|
can_use_profile: bool,
|
||||||
|
approval_required: bool,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
steps = [
|
||||||
|
"Open Settings, choose Mail profiles, and select Add profile in the current account's user-scoped view.",
|
||||||
|
"Enter a stable name and configure SMTP plus optional IMAP hostnames that satisfy every host-policy statement shown above.",
|
||||||
|
]
|
||||||
|
steps.append(
|
||||||
|
"Enter the required SMTP/IMAP credentials in the dedicated password fields."
|
||||||
|
if can_manage_credentials
|
||||||
|
else "Save the non-secret profile definition, then ask an actor with both profile-write and secret-management authority to add credentials if the server requires authentication."
|
||||||
|
)
|
||||||
|
steps.append("Save the profile; Mail validates the effective user-scope host policy again on the server.")
|
||||||
|
steps.append(
|
||||||
|
"Save the profile as active, then run the available SMTP and IMAP connection tests."
|
||||||
|
if can_test_profile
|
||||||
|
else "Ask an actor with both profile-test and profile-use authority to run the SMTP and IMAP connection tests after the profile is active."
|
||||||
|
)
|
||||||
|
if approval_required:
|
||||||
|
steps.append("Ask a Mail administrator to add the new profile to the active approved-profile list.")
|
||||||
|
steps.append(
|
||||||
|
"Select the profile from the consuming task's picker and complete that task's contextual validation."
|
||||||
|
if can_use_profile
|
||||||
|
else "Ask an actor with Mail profile use authority to select it in the consuming task after approval and testing."
|
||||||
|
)
|
||||||
|
return tuple(steps)
|
||||||
|
|
||||||
|
|
||||||
|
def _custom_profile_verification(*, can_test_profile: bool, can_use_profile: bool, approval_required: bool) -> str:
|
||||||
|
checks = ["Reopen My Mail profiles and confirm the saved profile remains in the current account's user-scoped view."]
|
||||||
|
checks.append(
|
||||||
|
"Confirm the authorized SMTP/IMAP tests succeed."
|
||||||
|
if can_test_profile
|
||||||
|
else "Have an authorized tester confirm the SMTP/IMAP tests succeed."
|
||||||
|
)
|
||||||
|
if approval_required:
|
||||||
|
checks.append("Confirm an administrator approved the generated profile reference before expecting it in a picker.")
|
||||||
|
checks.append(
|
||||||
|
"Confirm the intended task can select it and passes its own policy validation."
|
||||||
|
if can_use_profile
|
||||||
|
else "Confirm a separately authorized user can select it and passes the consuming task's policy validation."
|
||||||
|
)
|
||||||
|
return " ".join(checks)
|
||||||
|
|
||||||
|
|
||||||
def _mail_policy_admin_text(policy: dict[str, Any], *, source_count: int) -> tuple[str, str]:
|
def _mail_policy_admin_text(policy: dict[str, Any], *, source_count: int) -> tuple[str, str]:
|
||||||
allowed_profile_ids = policy.get("allowed_profile_ids")
|
allowed_profile_ids = policy.get("allowed_profile_ids")
|
||||||
lower_scopes = _allowed_lower_scopes(policy)
|
lower_scopes = _allowed_lower_scopes(policy)
|
||||||
@@ -259,5 +585,16 @@ def _has_any_scope(principal: object | None, scopes: tuple[str, ...]) -> bool:
|
|||||||
return "*" in principal_scopes or any(scope in principal_scopes for scope in scopes)
|
return "*" in principal_scopes or any(scope in principal_scopes for scope in scopes)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_all_scopes(principal: object | None, scopes: tuple[str, ...]) -> bool:
|
||||||
|
has = getattr(principal, "has", None)
|
||||||
|
if callable(has):
|
||||||
|
try:
|
||||||
|
return all(bool(has(scope)) for scope in scopes)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
principal_scopes = set(getattr(principal, "scopes", ()) or ())
|
||||||
|
return "*" in principal_scopes or all(scope in principal_scopes for scope in scopes)
|
||||||
|
|
||||||
|
|
||||||
def _mail_policy_api_link() -> DocumentationLink:
|
def _mail_policy_api_link() -> DocumentationLink:
|
||||||
return DocumentationLink(label="Tenant mail policy API", href="/api/v1/mail/policies/tenant", kind="api")
|
return DocumentationLink(label="Tenant mail policy API", href="/api/v1/mail/policies/tenant", kind="api")
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ 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_, text
|
from sqlalchemy import and_, or_, select, text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.admin.settings import get_system_settings
|
from govoplan_core.admin.settings import get_system_settings
|
||||||
@@ -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,
|
||||||
*,
|
*,
|
||||||
@@ -968,6 +981,7 @@ def mail_profile_visible_to_actor(
|
|||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
group_ids: Iterable[str] = (),
|
group_ids: Iterable[str] = (),
|
||||||
|
can_manage_own_profiles: bool = False,
|
||||||
can_manage_tenant_profiles: bool = False,
|
can_manage_tenant_profiles: bool = False,
|
||||||
can_manage_system_profiles: bool = False,
|
can_manage_system_profiles: bool = False,
|
||||||
tenant_admin: bool = False,
|
tenant_admin: bool = False,
|
||||||
@@ -978,8 +992,11 @@ def mail_profile_visible_to_actor(
|
|||||||
|
|
||||||
System and tenant profiles are shared definitions. Narrower profiles are
|
System and tenant profiles are shared definitions. Narrower profiles are
|
||||||
visible only to their owner context, unless the actor is a tenant-wide Mail
|
visible only to their owner context, unless the actor is a tenant-wide Mail
|
||||||
profile administrator. Campaign visibility is delegated to Campaign's ACL
|
profile administrator. In administrative views, a self-service actor may
|
||||||
capability so Mail never guesses another module's object permissions.
|
also see their exact user-owned profiles after policy makes them unusable,
|
||||||
|
so they can repair or deactivate them. Campaign visibility is delegated to
|
||||||
|
Campaign's ACL capability so Mail never guesses another module's object
|
||||||
|
permissions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
normalized_group_ids = tuple(sorted({str(group_id) for group_id in group_ids}))
|
normalized_group_ids = tuple(sorted({str(group_id) for group_id in group_ids}))
|
||||||
@@ -1003,6 +1020,13 @@ def mail_profile_visible_to_actor(
|
|||||||
return True
|
return True
|
||||||
if administrative_visibility and scope_type != "system" and can_manage_tenant_profiles:
|
if administrative_visibility and scope_type != "system" and can_manage_tenant_profiles:
|
||||||
return True
|
return True
|
||||||
|
if (
|
||||||
|
administrative_visibility
|
||||||
|
and can_manage_own_profiles
|
||||||
|
and scope_type == "user"
|
||||||
|
and scope_id == user_id
|
||||||
|
):
|
||||||
|
return True
|
||||||
if scope_type in {"system", "tenant"}:
|
if scope_type in {"system", "tenant"}:
|
||||||
return _profile_allowed_for_actor_policy(
|
return _profile_allowed_for_actor_policy(
|
||||||
session,
|
session,
|
||||||
@@ -1099,6 +1123,7 @@ def list_mail_server_profiles(
|
|||||||
campaign_id: str | None = None,
|
campaign_id: str | None = None,
|
||||||
actor_user_id: str | None = None,
|
actor_user_id: str | None = None,
|
||||||
actor_group_ids: Iterable[str] = (),
|
actor_group_ids: Iterable[str] = (),
|
||||||
|
actor_can_manage_own_profiles: bool = False,
|
||||||
actor_can_manage_tenant_profiles: bool = False,
|
actor_can_manage_tenant_profiles: bool = False,
|
||||||
actor_can_manage_system_profiles: bool = False,
|
actor_can_manage_system_profiles: bool = False,
|
||||||
actor_tenant_admin: bool = False,
|
actor_tenant_admin: bool = False,
|
||||||
@@ -1151,6 +1176,7 @@ def list_mail_server_profiles(
|
|||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
user_id=actor_user_id,
|
user_id=actor_user_id,
|
||||||
group_ids=normalized_actor_group_ids,
|
group_ids=normalized_actor_group_ids,
|
||||||
|
can_manage_own_profiles=actor_can_manage_own_profiles,
|
||||||
can_manage_tenant_profiles=actor_can_manage_tenant_profiles,
|
can_manage_tenant_profiles=actor_can_manage_tenant_profiles,
|
||||||
can_manage_system_profiles=actor_can_manage_system_profiles,
|
can_manage_system_profiles=actor_can_manage_system_profiles,
|
||||||
tenant_admin=actor_tenant_admin,
|
tenant_admin=actor_tenant_admin,
|
||||||
@@ -1168,6 +1194,7 @@ def get_mail_server_profile_for_actor(
|
|||||||
profile_id: str,
|
profile_id: str,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
group_ids: Iterable[str] = (),
|
group_ids: Iterable[str] = (),
|
||||||
|
can_manage_own_profiles: bool = False,
|
||||||
can_manage_tenant_profiles: bool = False,
|
can_manage_tenant_profiles: bool = False,
|
||||||
can_manage_system_profiles: bool = False,
|
can_manage_system_profiles: bool = False,
|
||||||
tenant_admin: bool = False,
|
tenant_admin: bool = False,
|
||||||
@@ -1186,6 +1213,7 @@ def get_mail_server_profile_for_actor(
|
|||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
group_ids=group_ids,
|
group_ids=group_ids,
|
||||||
|
can_manage_own_profiles=can_manage_own_profiles,
|
||||||
can_manage_tenant_profiles=can_manage_tenant_profiles,
|
can_manage_tenant_profiles=can_manage_tenant_profiles,
|
||||||
can_manage_system_profiles=can_manage_system_profiles,
|
can_manage_system_profiles=can_manage_system_profiles,
|
||||||
tenant_admin=tenant_admin,
|
tenant_admin=tenant_admin,
|
||||||
@@ -1204,7 +1232,21 @@ def get_mail_server_profile(
|
|||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
profile_id: str,
|
profile_id: str,
|
||||||
require_active: bool = False,
|
require_active: bool = False,
|
||||||
|
for_update: bool = False,
|
||||||
|
mutation_owner_user_id: str | None = None,
|
||||||
|
can_manage_tenant_profiles: bool = False,
|
||||||
|
can_manage_system_profiles: bool = False,
|
||||||
) -> MailServerProfile:
|
) -> MailServerProfile:
|
||||||
|
if for_update:
|
||||||
|
statement = _mail_server_profile_mutation_statement(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
owner_user_id=mutation_owner_user_id,
|
||||||
|
can_manage_tenant_profiles=can_manage_tenant_profiles,
|
||||||
|
can_manage_system_profiles=can_manage_system_profiles,
|
||||||
|
)
|
||||||
|
profile = session.execute(statement).scalar_one_or_none()
|
||||||
|
else:
|
||||||
profile = session.get(MailServerProfile, profile_id)
|
profile = session.get(MailServerProfile, profile_id)
|
||||||
if profile is None or (_profile_scope_type(profile) != "system" and profile.tenant_id != tenant_id):
|
if profile is None or (_profile_scope_type(profile) != "system" and profile.tenant_id != tenant_id):
|
||||||
raise MailProfileError("Mail-server profile not found")
|
raise MailProfileError("Mail-server profile not found")
|
||||||
@@ -1213,6 +1255,61 @@ def get_mail_server_profile(
|
|||||||
return profile
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
def _mail_server_profile_mutation_statement(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
profile_id: str,
|
||||||
|
owner_user_id: str | None,
|
||||||
|
can_manage_tenant_profiles: bool,
|
||||||
|
can_manage_system_profiles: bool,
|
||||||
|
):
|
||||||
|
"""Build the authorization-bounded row lock used by profile mutations.
|
||||||
|
|
||||||
|
The selector prevents a self-service actor from locking another owner's
|
||||||
|
row merely by guessing its identifier. ``populate_existing`` is essential:
|
||||||
|
after PostgreSQL waits for a concurrent writer, authorization and secret
|
||||||
|
checks must observe that writer's committed password and active state, not
|
||||||
|
an older identity-map snapshot.
|
||||||
|
"""
|
||||||
|
|
||||||
|
allowed_scopes = []
|
||||||
|
if can_manage_system_profiles:
|
||||||
|
allowed_scopes.append(
|
||||||
|
and_(
|
||||||
|
MailServerProfile.scope_type == "system",
|
||||||
|
MailServerProfile.tenant_id.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if can_manage_tenant_profiles:
|
||||||
|
allowed_scopes.append(
|
||||||
|
and_(
|
||||||
|
MailServerProfile.tenant_id == tenant_id,
|
||||||
|
MailServerProfile.scope_type != "system",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if owner_user_id:
|
||||||
|
allowed_scopes.append(
|
||||||
|
and_(
|
||||||
|
MailServerProfile.tenant_id == tenant_id,
|
||||||
|
MailServerProfile.scope_type == "user",
|
||||||
|
MailServerProfile.scope_id == owner_user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not allowed_scopes:
|
||||||
|
# Preserve a non-enumerating not-found result without locking an
|
||||||
|
# unauthorized row.
|
||||||
|
allowed_scopes.append(MailServerProfile.id.is_(None))
|
||||||
|
return (
|
||||||
|
select(MailServerProfile)
|
||||||
|
.where(
|
||||||
|
MailServerProfile.id == profile_id,
|
||||||
|
or_(*allowed_scopes),
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def ensure_mail_profile_allowed_for_campaign(
|
def ensure_mail_profile_allowed_for_campaign(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -1251,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:
|
||||||
@@ -1259,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:
|
||||||
@@ -1270,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."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1295,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(
|
||||||
@@ -1307,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(
|
||||||
@@ -1324,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
|
||||||
|
|
||||||
@@ -1340,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:
|
||||||
@@ -1382,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),
|
||||||
@@ -1407,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,
|
||||||
@@ -1454,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)
|
||||||
@@ -1764,6 +1919,8 @@ def delete_mail_profile_credentials_for_retirement(session: Session) -> int:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.order_by(MailServerProfile.id.asc())
|
.order_by(MailServerProfile.id.asc())
|
||||||
|
.with_for_update()
|
||||||
|
.populate_existing()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
deleted = 0
|
deleted = 0
|
||||||
@@ -1797,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": {
|
||||||
|
|||||||
@@ -6,12 +6,18 @@ from pathlib import Path
|
|||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||||
|
from govoplan_core.core.mail import (
|
||||||
|
CAPABILITY_MAIL_DELIVERY_OUTBOX,
|
||||||
|
CAPABILITY_MAIL_NOTIFICATION_DELIVERY,
|
||||||
|
)
|
||||||
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
DocumentationCondition,
|
DocumentationCondition,
|
||||||
|
DocumentationConfigurationProviderRegistration,
|
||||||
DocumentationLink,
|
DocumentationLink,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
|
FrontendRoute,
|
||||||
MigrationSpec,
|
MigrationSpec,
|
||||||
ModuleContext,
|
ModuleContext,
|
||||||
ModuleInterfaceProvider,
|
ModuleInterfaceProvider,
|
||||||
@@ -21,16 +27,25 @@ 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_configuration_states,
|
||||||
|
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,
|
||||||
mail_models.MailMailboxMessageIndex,
|
mail_models.MailMailboxMessageIndex,
|
||||||
|
mail_models.MailDeliveryReconciliation,
|
||||||
|
mail_models.MailDeliveryAttempt,
|
||||||
|
mail_models.MailDeliveryCommand,
|
||||||
label="Mail",
|
label="Mail",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -80,7 +95,27 @@ PERMISSIONS = (
|
|||||||
_permission("mail:profile:test", "Test mail profiles", "Run SMTP/IMAP connection tests."),
|
_permission("mail:profile:test", "Test mail profiles", "Run SMTP/IMAP connection tests."),
|
||||||
_permission("mail:mailbox:read", "Read mailboxes", "List IMAP folders and inspect messages without mutating mailbox state."),
|
_permission("mail:mailbox:read", "Read mailboxes", "List IMAP folders and inspect messages without mutating mailbox state."),
|
||||||
_permission("mail:profile:write", "Manage mail profiles", "Create and edit reusable mail profiles."),
|
_permission("mail:profile:write", "Manage mail profiles", "Create and edit reusable mail profiles."),
|
||||||
|
_permission(
|
||||||
|
"mail:profile:write_own",
|
||||||
|
"Manage own mail profiles",
|
||||||
|
"Create, edit, and deactivate only the current account's user-scoped mail profiles within effective policy.",
|
||||||
|
),
|
||||||
_permission("mail:secret:manage", "Manage mail secrets", "Create or replace stored SMTP/IMAP credentials."),
|
_permission("mail:secret:manage", "Manage mail secrets", "Create or replace stored SMTP/IMAP credentials."),
|
||||||
|
_permission(
|
||||||
|
"mail:delivery:diagnostic",
|
||||||
|
"Inspect mail delivery diagnostics",
|
||||||
|
"Inspect bounded recipient-level refusal and attempt evidence for durable Mail commands.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"mail:delivery:reconcile",
|
||||||
|
"Reconcile mail delivery outcomes",
|
||||||
|
"Reconcile unknown delivery outcomes and explicitly authorize deliberate resend commands.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"mail:secret:manage_own",
|
||||||
|
"Manage own mail secrets",
|
||||||
|
"Create, replace, and delete credentials only for the current account's user-scoped mail profiles.",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
ROLE_TEMPLATES = (
|
ROLE_TEMPLATES = (
|
||||||
@@ -95,6 +130,8 @@ ROLE_TEMPLATES = (
|
|||||||
"mail:mailbox:read",
|
"mail:mailbox:read",
|
||||||
"mail:profile:write",
|
"mail:profile:write",
|
||||||
"mail:secret:manage",
|
"mail:secret:manage",
|
||||||
|
"mail:delivery:diagnostic",
|
||||||
|
"mail:delivery:reconcile",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
@@ -103,6 +140,19 @@ ROLE_TEMPLATES = (
|
|||||||
description="Use and test approved mail profiles without reading secrets.",
|
description="Use and test approved mail profiles without reading secrets.",
|
||||||
permissions=("mail:profile:read", "mail:profile:use", "mail:profile:test", "mail:mailbox:read"),
|
permissions=("mail:profile:read", "mail:profile:use", "mail:profile:test", "mail:mailbox:read"),
|
||||||
),
|
),
|
||||||
|
RoleTemplate(
|
||||||
|
slug="mail_profile_self_service",
|
||||||
|
name="Mail profile self-service user",
|
||||||
|
description="Create and manage only personal Mail profiles and credentials within effective policy.",
|
||||||
|
permissions=(
|
||||||
|
"mail:profile:read",
|
||||||
|
"mail:profile:use",
|
||||||
|
"mail:profile:test",
|
||||||
|
"mail:mailbox:read",
|
||||||
|
"mail:profile:write_own",
|
||||||
|
"mail:secret:manage_own",
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -126,11 +176,14 @@ def _mail_router(context: ModuleContext):
|
|||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="mail",
|
id="mail",
|
||||||
name="Mail",
|
name="Mail",
|
||||||
version="0.1.9",
|
version="0.1.10",
|
||||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||||
optional_dependencies=("campaigns", "addresses"),
|
optional_dependencies=("campaigns", "addresses"),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name="mail.campaign_delivery", version="0.2.0"),
|
ModuleInterfaceProvider(name="mail.campaign_delivery", version="0.2.0"),
|
||||||
|
ModuleInterfaceProvider(name="mail.delivery_commands", version="0.1.0"),
|
||||||
|
ModuleInterfaceProvider(name="mail.delivery_outbox", version="0.1.0"),
|
||||||
|
ModuleInterfaceProvider(name="mail.notification_delivery", version="0.1.0"),
|
||||||
),
|
),
|
||||||
requires_interfaces=(
|
requires_interfaces=(
|
||||||
ModuleInterfaceRequirement(
|
ModuleInterfaceRequirement(
|
||||||
@@ -159,7 +212,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",
|
||||||
@@ -171,15 +239,28 @@ 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,
|
||||||
mail_models.MailMailboxMessageIndex,
|
mail_models.MailMailboxMessageIndex,
|
||||||
|
mail_models.MailDeliveryReconciliation,
|
||||||
|
mail_models.MailDeliveryAttempt,
|
||||||
|
mail_models.MailDeliveryCommand,
|
||||||
label="Mail",
|
label="Mail",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
capability_factories={
|
capability_factories={
|
||||||
"mail.campaign_delivery": lambda context: __import__("govoplan_mail.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context),
|
"mail.campaign_delivery": lambda context: __import__("govoplan_mail.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context),
|
||||||
|
CAPABILITY_MAIL_DELIVERY_OUTBOX: lambda context: __import__(
|
||||||
|
"govoplan_mail.backend.capabilities",
|
||||||
|
fromlist=["delivery_outbox_capability"],
|
||||||
|
).delivery_outbox_capability(context),
|
||||||
|
CAPABILITY_MAIL_NOTIFICATION_DELIVERY: lambda context: __import__(
|
||||||
|
"govoplan_mail.backend.capabilities",
|
||||||
|
fromlist=["notification_delivery_capability"],
|
||||||
|
).notification_delivery_capability(context),
|
||||||
},
|
},
|
||||||
documentation=(
|
documentation=(
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
@@ -231,7 +312,12 @@ manifest = ModuleManifest(
|
|||||||
conditions=(
|
conditions=(
|
||||||
DocumentationCondition(
|
DocumentationCondition(
|
||||||
required_modules=("mail",),
|
required_modules=("mail",),
|
||||||
any_scopes=("mail:profile:read", "mail:profile:use", "mail:profile:write"),
|
any_scopes=(
|
||||||
|
"mail:profile:read",
|
||||||
|
"mail:profile:use",
|
||||||
|
"mail:profile:write",
|
||||||
|
"mail:profile:write_own",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
links=(
|
links=(
|
||||||
@@ -279,6 +365,7 @@ manifest = ModuleManifest(
|
|||||||
"kind": "workflow",
|
"kind": "workflow",
|
||||||
"route": "/settings?section=mail-profiles",
|
"route": "/settings?section=mail-profiles",
|
||||||
"screen": "Mail profiles",
|
"screen": "Mail profiles",
|
||||||
|
"help_contexts": ["mail.profiles", "app.settings"],
|
||||||
"prerequisites": [
|
"prerequisites": [
|
||||||
"Mail is installed and you may read, use, and test profiles visible in the current context.",
|
"Mail is installed and you may read, use, and test profiles visible in the current context.",
|
||||||
"A profile administrator has configured credentials and effective policy.",
|
"A profile administrator has configured credentials and effective policy.",
|
||||||
@@ -321,6 +408,7 @@ manifest = ModuleManifest(
|
|||||||
"kind": "workflow",
|
"kind": "workflow",
|
||||||
"route": "/mail",
|
"route": "/mail",
|
||||||
"screen": "Mail",
|
"screen": "Mail",
|
||||||
|
"help_contexts": ["mail.list"],
|
||||||
"prerequisites": [
|
"prerequisites": [
|
||||||
"An active visible profile has IMAP configured.",
|
"An active visible profile has IMAP configured.",
|
||||||
"You may both use that profile and read its mailbox.",
|
"You may both use that profile and read its mailbox.",
|
||||||
@@ -376,7 +464,7 @@ manifest = ModuleManifest(
|
|||||||
id="mail.reference.campaign-delivery-contract",
|
id="mail.reference.campaign-delivery-contract",
|
||||||
title="Integrate Campaign through the Mail delivery contract",
|
title="Integrate Campaign through the Mail delivery contract",
|
||||||
summary="Campaign freezes a Mail profile reference and opaque revision; Mail re-authorizes, revision-checks, resolves credentials, and performs the effect in one call.",
|
summary="Campaign freezes a Mail profile reference and opaque revision; Mail re-authorizes, revision-checks, resolves credentials, and performs the effect in one call.",
|
||||||
body="The mail.campaign_delivery 0.2 contract never returns decrypted credentials or resolved SMTP/IMAP configuration. Mail compares the expected random transport revision before decrypting protocol-specific credentials and returns only bounded sanitized outcomes. Campaign owns durable recipient jobs, retry, and reconciliation. Live report emailing remains disabled until the planned Mail-owned idempotent outbox and attempt ledger are implemented.",
|
body="The mail.campaign_delivery 0.2 contract never returns decrypted credentials or resolved SMTP/IMAP configuration. Mail compares the expected random transport revision before decrypting protocol-specific credentials and returns only bounded sanitized outcomes. Campaign owns ordinary recipient jobs; report messages use Mail's encrypted idempotent delivery-command and attempt ledger. Effect-start evidence prevents blind redelivery, unknown outcomes require explicit reconciliation, and raw recipient refusals require Mail diagnostic authority.",
|
||||||
layer="available",
|
layer="available",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("integrator", "campaign_manager", "campaign_sender", "release_reviewer"),
|
audience=("integrator", "campaign_manager", "campaign_sender", "release_reviewer"),
|
||||||
@@ -409,6 +497,12 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
documentation_providers=(documentation_topics,),
|
documentation_providers=(documentation_topics,),
|
||||||
|
documentation_configuration_providers=(
|
||||||
|
DocumentationConfigurationProviderRegistration(
|
||||||
|
keys=("mail_profile_policy",),
|
||||||
|
resolve=documentation_configuration_states,
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,22 @@
|
|||||||
|
"""add durable mail delivery outbox
|
||||||
|
|
||||||
|
Revision ID: 82a3b4c5d6e7
|
||||||
|
Revises: 7192a3bcdef0
|
||||||
|
Create Date: 2026-07-30 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
|
||||||
|
|
||||||
|
delivery_outbox = import_module(
|
||||||
|
"govoplan_mail.backend.migrations.versions.82a3b4c5d6e7_mail_delivery_outbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
revision = delivery_outbox.revision
|
||||||
|
down_revision = delivery_outbox.down_revision
|
||||||
|
branch_labels = delivery_outbox.branch_labels
|
||||||
|
depends_on = delivery_outbox.depends_on
|
||||||
|
upgrade = delivery_outbox.upgrade
|
||||||
|
downgrade = delivery_outbox.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 {}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
"""add durable mail delivery outbox
|
||||||
|
|
||||||
|
Revision ID: 82a3b4c5d6e7
|
||||||
|
Revises: 7192a3bcdef0
|
||||||
|
Create Date: 2026-07-30 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "82a3b4c5d6e7"
|
||||||
|
down_revision = "7192a3bcdef0"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = "c91f0a72be34"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
tables = set(inspector.get_table_names())
|
||||||
|
if "mail_delivery_commands" not in tables:
|
||||||
|
op.create_table(
|
||||||
|
"mail_delivery_commands",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("command_type", sa.String(length=60), nullable=False),
|
||||||
|
sa.Column("source_module", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("source_resource_type", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("source_resource_id", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("source_version_id", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("idempotency_key", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("canonical_request_hash", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("profile_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("smtp_server_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("smtp_credential_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"expected_smtp_transport_revision",
|
||||||
|
sa.String(length=120),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("envelope_from_encrypted", sa.Text(), nullable=True),
|
||||||
|
sa.Column("envelope_recipients_encrypted", sa.Text(), nullable=True),
|
||||||
|
sa.Column("from_header_encrypted", sa.Text(), nullable=True),
|
||||||
|
sa.Column("message_encrypted", sa.Text(), nullable=True),
|
||||||
|
sa.Column("message_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("message_size_bytes", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("recipient_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("attempt_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("claimed_by", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("effect_started_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("accepted_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("refused_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("refusal_summary", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("refusal_details_encrypted", sa.Text(), nullable=True),
|
||||||
|
sa.Column("failure_code", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("failure_summary", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("supersedes_command_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("payload_purged_at", sa.DateTime(timezone=True), 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_delivery_commands_profile_id_mail_server_profiles"
|
||||||
|
),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["created_by_user_id"],
|
||||||
|
["access_users.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_mail_delivery_commands_created_by_user_id_access_users"
|
||||||
|
),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["supersedes_command_id"],
|
||||||
|
["mail_delivery_commands.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_mail_delivery_commands_supersedes_command_id_mail_delivery_commands"
|
||||||
|
),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint(
|
||||||
|
"id",
|
||||||
|
name=op.f("pk_mail_delivery_commands"),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"command_type",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_mail_delivery_commands_idempotency",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_mail_delivery_commands_dispatch",
|
||||||
|
"mail_delivery_commands",
|
||||||
|
["status", "next_attempt_at", "created_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_mail_delivery_commands_source",
|
||||||
|
"mail_delivery_commands",
|
||||||
|
[
|
||||||
|
"tenant_id",
|
||||||
|
"source_module",
|
||||||
|
"source_resource_type",
|
||||||
|
"source_resource_id",
|
||||||
|
],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"tenant_id",
|
||||||
|
"command_type",
|
||||||
|
"source_module",
|
||||||
|
"profile_id",
|
||||||
|
"status",
|
||||||
|
"next_attempt_at",
|
||||||
|
"created_by_user_id",
|
||||||
|
"supersedes_command_id",
|
||||||
|
"expires_at",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_mail_delivery_commands_{column}"),
|
||||||
|
"mail_delivery_commands",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "mail_delivery_attempts" not in inspector.get_table_names():
|
||||||
|
op.create_table(
|
||||||
|
"mail_delivery_attempts",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("command_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("attempt_number", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("worker_id", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("effect_started_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("accepted_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("refused_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("outcome_code", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("diagnostic_summary", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["command_id"],
|
||||||
|
["mail_delivery_commands.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_mail_delivery_attempts_command_id_mail_delivery_commands"
|
||||||
|
),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint(
|
||||||
|
"id",
|
||||||
|
name=op.f("pk_mail_delivery_attempts"),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"command_id",
|
||||||
|
"attempt_number",
|
||||||
|
name="uq_mail_delivery_attempts_number",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_mail_delivery_attempts_command_started",
|
||||||
|
"mail_delivery_attempts",
|
||||||
|
["command_id", "started_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
for column in ("command_id", "status"):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_mail_delivery_attempts_{column}"),
|
||||||
|
"mail_delivery_attempts",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "mail_delivery_reconciliations" not in inspector.get_table_names():
|
||||||
|
op.create_table(
|
||||||
|
"mail_delivery_reconciliations",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("command_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("decision", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("evidence_reference", sa.String(length=500), nullable=False),
|
||||||
|
sa.Column("note_encrypted", sa.Text(), nullable=True),
|
||||||
|
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(
|
||||||
|
["command_id"],
|
||||||
|
["mail_delivery_commands.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_mail_delivery_reconciliations_command_id_mail_delivery_commands"
|
||||||
|
),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["created_by_user_id"],
|
||||||
|
["access_users.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_mail_delivery_reconciliations_created_by_user_id_access_users"
|
||||||
|
),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint(
|
||||||
|
"id",
|
||||||
|
name=op.f("pk_mail_delivery_reconciliations"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_mail_delivery_reconciliations_command_created",
|
||||||
|
"mail_delivery_reconciliations",
|
||||||
|
["command_id", "created_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
for column in ("command_id", "created_by_user_id"):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_mail_delivery_reconciliations_{column}"),
|
||||||
|
"mail_delivery_reconciliations",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
tables = set(sa.inspect(op.get_bind()).get_table_names())
|
||||||
|
if "mail_delivery_reconciliations" in tables:
|
||||||
|
op.drop_table("mail_delivery_reconciliations")
|
||||||
|
if "mail_delivery_attempts" in tables:
|
||||||
|
op.drop_table("mail_delivery_attempts")
|
||||||
|
if "mail_delivery_commands" in tables:
|
||||||
|
op.drop_table("mail_delivery_commands")
|
||||||
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
|
||||||
|
|
||||||
@@ -311,3 +436,21 @@ class MailMailboxMessageResponse(BaseModel):
|
|||||||
port: int | None = None
|
port: int | None = None
|
||||||
security: str | None = None
|
security: str | None = None
|
||||||
message: MailMailboxMessageDetailResponse
|
message: MailMailboxMessageDetailResponse
|
||||||
|
|
||||||
|
|
||||||
|
class MailDeliveryCommandResponse(BaseModel):
|
||||||
|
result: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class MailDeliveryReconcileRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
decision: Literal["accepted", "not_accepted"]
|
||||||
|
evidence_reference: str = Field(min_length=1, max_length=500)
|
||||||
|
note: str | None = Field(default=None, max_length=4000)
|
||||||
|
|
||||||
|
|
||||||
|
class MailDeliveryResendRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=200)
|
||||||
|
|||||||
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
294
tests/test_delivery_outbox.py
Normal file
294
tests/test_delivery_outbox.py
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import timedelta
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from sqlalchemy import Column, String, Table, create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_mail.backend.db.models import (
|
||||||
|
MailDeliveryAttempt,
|
||||||
|
MailDeliveryCommand,
|
||||||
|
MailDeliveryReconciliation,
|
||||||
|
MailServerProfile,
|
||||||
|
)
|
||||||
|
from govoplan_mail.backend.delivery_outbox import (
|
||||||
|
MailDeliveryIdempotencyConflict,
|
||||||
|
delivery_command_diagnostics,
|
||||||
|
dispatch_due,
|
||||||
|
purge_expired,
|
||||||
|
submit_delivery_command,
|
||||||
|
utcnow,
|
||||||
|
)
|
||||||
|
from govoplan_mail.backend.sending.smtp import SmtpSendError
|
||||||
|
|
||||||
|
|
||||||
|
class MailDeliveryOutboxTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine(
|
||||||
|
"sqlite+pysqlite://",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
access_users = Base.metadata.tables.get("access_users")
|
||||||
|
if access_users is None:
|
||||||
|
access_users = Table(
|
||||||
|
"access_users",
|
||||||
|
Base.metadata,
|
||||||
|
Column("id", String(36), primary_key=True),
|
||||||
|
)
|
||||||
|
Base.metadata.create_all(
|
||||||
|
self.engine,
|
||||||
|
tables=[
|
||||||
|
access_users,
|
||||||
|
MailServerProfile.__table__,
|
||||||
|
MailDeliveryCommand.__table__,
|
||||||
|
MailDeliveryAttempt.__table__,
|
||||||
|
MailDeliveryReconciliation.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.SessionLocal = sessionmaker(
|
||||||
|
bind=self.engine,
|
||||||
|
class_=Session,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
session.add(
|
||||||
|
MailServerProfile(
|
||||||
|
id="profile-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id="tenant-1",
|
||||||
|
name="Delivery",
|
||||||
|
slug="delivery",
|
||||||
|
smtp_config={"host": "smtp.example.test", "port": 25},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
self.audit = patch(
|
||||||
|
"govoplan_mail.backend.delivery_outbox.audit_event"
|
||||||
|
)
|
||||||
|
self.audit.start()
|
||||||
|
self.addCleanup(self.audit.stop)
|
||||||
|
self.addCleanup(self.engine.dispose)
|
||||||
|
|
||||||
|
def _submit(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
key: str = "request-1",
|
||||||
|
recipients: list[str] | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return submit_delivery_command(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
command_type="campaign_report",
|
||||||
|
source_module="campaigns",
|
||||||
|
source_resource_type="campaign",
|
||||||
|
source_resource_id="campaign-1",
|
||||||
|
source_version_id="version-1",
|
||||||
|
idempotency_key=key,
|
||||||
|
profile_id="profile-1",
|
||||||
|
message_bytes=b"Subject: report\r\n\r\ncontent",
|
||||||
|
envelope_from="sender@example.test",
|
||||||
|
envelope_recipients=recipients or ["recipient@example.test"],
|
||||||
|
from_header="Sender <sender@example.test>",
|
||||||
|
expected_smtp_transport_revision="revision-1",
|
||||||
|
created_by_user_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_submission_is_idempotent_and_conflicts_on_changed_intent(self) -> None:
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
first = self._submit(session)
|
||||||
|
session.commit()
|
||||||
|
repeated = self._submit(session)
|
||||||
|
|
||||||
|
self.assertEqual(first["id"], repeated["id"])
|
||||||
|
self.assertTrue(repeated["duplicate"])
|
||||||
|
with self.assertRaises(MailDeliveryIdempotencyConflict):
|
||||||
|
self._submit(
|
||||||
|
session,
|
||||||
|
recipients=["different@example.test"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_attempt_is_committed_before_effect_and_accepted_is_not_resent(self) -> None:
|
||||||
|
observed: dict[str, object] = {}
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
command_id = str(self._submit(session)["id"])
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
def provider_effect(session: Session, **_kwargs):
|
||||||
|
command = session.get(MailDeliveryCommand, command_id)
|
||||||
|
attempt = session.query(MailDeliveryAttempt).one()
|
||||||
|
observed.update(
|
||||||
|
command_status=command.status,
|
||||||
|
effect_started=command.effect_started_at is not None,
|
||||||
|
attempt_status=attempt.status,
|
||||||
|
)
|
||||||
|
return SimpleNamespace(
|
||||||
|
accepted_count=1,
|
||||||
|
refused_recipients={},
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
self.SessionLocal() as session,
|
||||||
|
patch(
|
||||||
|
"govoplan_mail.backend.delivery_outbox.send_campaign_email_bytes",
|
||||||
|
side_effect=provider_effect,
|
||||||
|
) as send,
|
||||||
|
):
|
||||||
|
result = dispatch_due(session, worker_id="worker-1")
|
||||||
|
repeated = dispatch_due(session, worker_id="worker-2")
|
||||||
|
|
||||||
|
self.assertEqual(result["accepted"], 1)
|
||||||
|
self.assertEqual(repeated["selected"], 0)
|
||||||
|
self.assertEqual(send.call_count, 1)
|
||||||
|
self.assertEqual(
|
||||||
|
observed,
|
||||||
|
{
|
||||||
|
"command_status": "in_progress",
|
||||||
|
"effect_started": True,
|
||||||
|
"attempt_status": "in_progress",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_partial_refusal_is_terminal_and_diagnostics_are_separate(self) -> None:
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
command_id = str(
|
||||||
|
self._submit(
|
||||||
|
session,
|
||||||
|
recipients=["accepted@example.test", "blocked@example.test"],
|
||||||
|
)["id"]
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
with patch(
|
||||||
|
"govoplan_mail.backend.delivery_outbox.send_campaign_email_bytes",
|
||||||
|
return_value=SimpleNamespace(
|
||||||
|
accepted_count=1,
|
||||||
|
refused_recipients={
|
||||||
|
"blocked@example.test": {
|
||||||
|
"status_code": 550,
|
||||||
|
"classification": "permanent",
|
||||||
|
"message": "Permanent recipient rejection",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = dispatch_due(session)
|
||||||
|
command = session.get(MailDeliveryCommand, command_id)
|
||||||
|
assert command is not None
|
||||||
|
self.assertEqual(result["partially_refused"], 1)
|
||||||
|
self.assertNotIn("blocked@example.test", repr({
|
||||||
|
"status": command.status,
|
||||||
|
"summary": command.refusal_summary,
|
||||||
|
}))
|
||||||
|
diagnostics = delivery_command_diagnostics(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
command_id=command_id,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"blocked@example.test",
|
||||||
|
diagnostics["refused_recipients"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unknown_outcome_is_never_automatically_retried(self) -> None:
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
command_id = str(self._submit(session)["id"])
|
||||||
|
session.commit()
|
||||||
|
with patch(
|
||||||
|
"govoplan_mail.backend.delivery_outbox.send_campaign_email_bytes",
|
||||||
|
side_effect=SmtpSendError(
|
||||||
|
"unknown",
|
||||||
|
outcome_unknown=True,
|
||||||
|
),
|
||||||
|
) as send:
|
||||||
|
first = dispatch_due(session)
|
||||||
|
second = dispatch_due(session)
|
||||||
|
command = session.get(MailDeliveryCommand, command_id)
|
||||||
|
|
||||||
|
assert command is not None
|
||||||
|
self.assertEqual(first["outcome_unknown"], 1)
|
||||||
|
self.assertEqual(second["selected"], 0)
|
||||||
|
self.assertEqual(command.status, "outcome_unknown")
|
||||||
|
self.assertEqual(send.call_count, 1)
|
||||||
|
|
||||||
|
def test_audit_failure_after_acceptance_cannot_make_command_retryable(self) -> None:
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
command_id = str(self._submit(session)["id"])
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with (
|
||||||
|
self.SessionLocal() as session,
|
||||||
|
patch(
|
||||||
|
"govoplan_mail.backend.delivery_outbox.send_campaign_email_bytes",
|
||||||
|
return_value=SimpleNamespace(
|
||||||
|
accepted_count=1,
|
||||||
|
refused_recipients={},
|
||||||
|
),
|
||||||
|
) as send,
|
||||||
|
patch(
|
||||||
|
"govoplan_mail.backend.delivery_outbox.audit_event",
|
||||||
|
side_effect=RuntimeError("audit unavailable"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "audit unavailable"):
|
||||||
|
dispatch_due(session)
|
||||||
|
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
command = session.get(MailDeliveryCommand, command_id)
|
||||||
|
assert command is not None
|
||||||
|
self.assertEqual(command.status, "accepted")
|
||||||
|
self.assertEqual(dispatch_due(session)["selected"], 0)
|
||||||
|
self.assertEqual(send.call_count, 1)
|
||||||
|
|
||||||
|
def test_stale_effect_started_command_becomes_unknown_without_send(self) -> None:
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
command_id = str(self._submit(session)["id"])
|
||||||
|
session.commit()
|
||||||
|
command = session.get(MailDeliveryCommand, command_id)
|
||||||
|
assert command is not None
|
||||||
|
command.status = "in_progress"
|
||||||
|
command.attempt_count = 1
|
||||||
|
command.claimed_at = utcnow() - timedelta(hours=1)
|
||||||
|
command.effect_started_at = utcnow() - timedelta(hours=1)
|
||||||
|
session.add(
|
||||||
|
MailDeliveryAttempt(
|
||||||
|
command_id=command.id,
|
||||||
|
attempt_number=1,
|
||||||
|
status="in_progress",
|
||||||
|
started_at=utcnow() - timedelta(hours=1),
|
||||||
|
effect_started_at=utcnow() - timedelta(hours=1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
with patch(
|
||||||
|
"govoplan_mail.backend.delivery_outbox.send_campaign_email_bytes"
|
||||||
|
) as send:
|
||||||
|
result = dispatch_due(session)
|
||||||
|
self.assertEqual(result["outcome_unknown"], 1)
|
||||||
|
self.assertEqual(command.status, "outcome_unknown")
|
||||||
|
send.assert_not_called()
|
||||||
|
|
||||||
|
def test_expired_payload_is_minimized_but_evidence_remains(self) -> None:
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
command_id = str(self._submit(session)["id"])
|
||||||
|
session.commit()
|
||||||
|
command = session.get(MailDeliveryCommand, command_id)
|
||||||
|
assert command is not None
|
||||||
|
command.expires_at = utcnow() - timedelta(seconds=1)
|
||||||
|
session.commit()
|
||||||
|
self.assertEqual(purge_expired(session), {"purged": 1})
|
||||||
|
session.refresh(command)
|
||||||
|
self.assertIsNone(command.message_encrypted)
|
||||||
|
self.assertIsNone(command.envelope_recipients_encrypted)
|
||||||
|
self.assertIsNotNone(command.payload_purged_at)
|
||||||
|
self.assertEqual(command.message_sha256, command.message_sha256)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
231
tests/test_documentation.py
Normal file
231
tests/test_documentation.py
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import DocumentationContext
|
||||||
|
from govoplan_mail.backend.documentation import (
|
||||||
|
documentation_configuration_states,
|
||||||
|
documentation_topics,
|
||||||
|
)
|
||||||
|
from govoplan_mail.backend.mail_profiles import EffectiveMailProfilePolicy, MailProfileError
|
||||||
|
|
||||||
|
|
||||||
|
class _Principal:
|
||||||
|
tenant_id = "tenant-1"
|
||||||
|
user = SimpleNamespace(id="user-1")
|
||||||
|
|
||||||
|
def __init__(self, scopes: set[str]) -> None:
|
||||||
|
self.scopes = frozenset(scopes)
|
||||||
|
|
||||||
|
def has(self, scope: str) -> bool:
|
||||||
|
return scope in self.scopes
|
||||||
|
|
||||||
|
|
||||||
|
class MailRuntimeDocumentationTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.session = Session()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
|
||||||
|
def context(self, scopes: set[str], *, documentation_type: str = "user") -> DocumentationContext:
|
||||||
|
return DocumentationContext(
|
||||||
|
registry=object(),
|
||||||
|
principal=_Principal(scopes),
|
||||||
|
settings=None,
|
||||||
|
session=self.session,
|
||||||
|
documentation_type=documentation_type, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
def topics(self, context: DocumentationContext, user_policy: EffectiveMailProfilePolicy) -> dict[str, object]:
|
||||||
|
tenant_policy = EffectiveMailProfilePolicy()
|
||||||
|
|
||||||
|
def effective_policy(_session, *, scope_type, **_kwargs):
|
||||||
|
return user_policy if scope_type == "user" else tenant_policy
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_mail.backend.documentation.effective_mail_profile_policy_for_scope",
|
||||||
|
side_effect=effective_policy,
|
||||||
|
):
|
||||||
|
return {topic.id: topic for topic in documentation_topics(context)}
|
||||||
|
|
||||||
|
def test_custom_profile_task_requires_write_authority_and_enabled_user_profiles(self) -> None:
|
||||||
|
enabled = EffectiveMailProfilePolicy(allow_user_profiles=True)
|
||||||
|
without_write = self.topics(self.context({"mail:profile:read"}), enabled)
|
||||||
|
self.assertNotIn("mail.workflow.create-custom-profile", without_write)
|
||||||
|
|
||||||
|
without_read = self.topics(self.context({"mail:profile:write"}), enabled)
|
||||||
|
self.assertNotIn("mail.workflow.create-custom-profile", without_read)
|
||||||
|
|
||||||
|
disabled = EffectiveMailProfilePolicy(allow_user_profiles=False)
|
||||||
|
blocked = self.topics(self.context({"mail:profile:read", "mail:profile:write"}), disabled)
|
||||||
|
self.assertNotIn("mail.workflow.create-custom-profile", blocked)
|
||||||
|
|
||||||
|
available = self.topics(self.context({"mail:profile:read", "mail:profile:write"}), enabled)
|
||||||
|
self.assertIn("mail.workflow.create-custom-profile", available)
|
||||||
|
task = available["mail.workflow.create-custom-profile"]
|
||||||
|
self.assertEqual(task.title, "Create a custom Mail profile")
|
||||||
|
self.assertEqual(task.metadata["route"], "/settings?section=mail-profiles")
|
||||||
|
self.assertEqual(task.metadata["help_contexts"], ["mail.profiles", "app.settings"])
|
||||||
|
self.assertIn("current account's user scope", task.metadata["prerequisites"][0])
|
||||||
|
|
||||||
|
self_service = self.topics(
|
||||||
|
self.context(
|
||||||
|
{"mail:profile:read", "mail:profile:write_own"}
|
||||||
|
),
|
||||||
|
enabled,
|
||||||
|
)
|
||||||
|
self.assertIn("mail.workflow.create-custom-profile", self_service)
|
||||||
|
condition = self_service["mail.workflow.create-custom-profile"].conditions[0]
|
||||||
|
self.assertEqual(condition.required_scopes, ("mail:profile:read",))
|
||||||
|
self.assertEqual(
|
||||||
|
condition.any_scopes,
|
||||||
|
("mail:profile:write", "mail:profile:write_own"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_custom_profile_task_distinguishes_secret_test_and_use_authority(self) -> None:
|
||||||
|
policy = EffectiveMailProfilePolicy()
|
||||||
|
cases = (
|
||||||
|
(set(), False, False, False),
|
||||||
|
({"mail:secret:manage"}, True, False, False),
|
||||||
|
({"mail:profile:test"}, False, False, False),
|
||||||
|
({"mail:profile:use"}, False, False, True),
|
||||||
|
({"mail:profile:test", "mail:profile:use"}, False, True, True),
|
||||||
|
({"mail:secret:manage", "mail:profile:test", "mail:profile:use"}, True, True, True),
|
||||||
|
({"mail:secret:manage_own"}, True, False, False),
|
||||||
|
)
|
||||||
|
for extra_scopes, credentials, testing, use in cases:
|
||||||
|
with self.subTest(extra_scopes=extra_scopes):
|
||||||
|
topics = self.topics(self.context({"mail:profile:read", "mail:profile:write", *extra_scopes}), policy)
|
||||||
|
task = topics["mail.workflow.create-custom-profile"]
|
||||||
|
self.assertEqual(task.metadata["can_manage_credentials"], credentials)
|
||||||
|
self.assertEqual(task.metadata["can_test_profile"], testing)
|
||||||
|
self.assertEqual(task.metadata["can_use_profile"], use)
|
||||||
|
self.assertIn(
|
||||||
|
"you may save or replace" if credentials else "you cannot save or replace passwords",
|
||||||
|
task.body,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"you may run" if testing else "does not let you run connection tests",
|
||||||
|
task.body,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"you may select" if use else "does not let you select or use it",
|
||||||
|
task.body,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_custom_profile_task_preserves_host_allow_group_and_deny_precedence(self) -> None:
|
||||||
|
policy = EffectiveMailProfilePolicy(
|
||||||
|
whitelist_groups={
|
||||||
|
"smtp_hosts": [
|
||||||
|
["*.example.edu", "smtp.shared.test"],
|
||||||
|
["smtp-*.example.edu"],
|
||||||
|
],
|
||||||
|
"imap_hosts": [["imap.example.edu"]],
|
||||||
|
},
|
||||||
|
blacklist_patterns={
|
||||||
|
"smtp_hosts": ["smtp-blocked.example.edu"],
|
||||||
|
"imap_hosts": ["imap-legacy.example.edu"],
|
||||||
|
},
|
||||||
|
allowed_profile_id_sets=[{"private-profile-id"}],
|
||||||
|
)
|
||||||
|
topics = self.topics(
|
||||||
|
self.context({"mail:profile:read", "mail:profile:write", "mail:secret:manage", "mail:profile:test", "mail:profile:use"}),
|
||||||
|
policy,
|
||||||
|
)
|
||||||
|
task = topics["mail.workflow.create-custom-profile"]
|
||||||
|
constraints = {item["id"]: item for item in task.metadata["constraints"]}
|
||||||
|
constraints_text = " ".join(item["description"] for item in task.metadata["constraints"])
|
||||||
|
|
||||||
|
self.assertIn("Deny rules are checked first", constraints_text)
|
||||||
|
self.assertEqual(constraints["smtp-host-deny"]["values"], ["smtp-blocked.example.edu"])
|
||||||
|
self.assertEqual(constraints["smtp-host-allow-1"]["values"], ["*.example.edu", "smtp.shared.test"])
|
||||||
|
self.assertEqual(constraints["smtp-host-allow-2"]["values"], ["smtp-*.example.edu"])
|
||||||
|
self.assertIn("every active allow-list group", constraints_text)
|
||||||
|
self.assertEqual(constraints["imap-host-allow-1"]["values"], ["imap.example.edu"])
|
||||||
|
self.assertTrue(task.metadata["approval_required_before_use"])
|
||||||
|
self.assertIn("must be approved before the profile can be selected or used", task.body)
|
||||||
|
self.assertNotIn("private-profile-id", repr(task))
|
||||||
|
self.assertIn("every active allow-list group", constraints["smtp-host-allow-1"]["description"])
|
||||||
|
|
||||||
|
def test_custom_profile_task_explains_when_no_approval_list_is_active(self) -> None:
|
||||||
|
topics = self.topics(
|
||||||
|
self.context({"mail:profile:read", "mail:profile:write", "mail:profile:use"}),
|
||||||
|
EffectiveMailProfilePolicy(),
|
||||||
|
)
|
||||||
|
task = topics["mail.workflow.create-custom-profile"]
|
||||||
|
self.assertFalse(task.metadata["approval_required_before_use"])
|
||||||
|
self.assertIn("no approved-profile list currently blocks", task.body)
|
||||||
|
|
||||||
|
def test_user_policy_errors_are_generic_and_never_create_a_task(self) -> None:
|
||||||
|
context = self.context({"mail:profile:read", "mail:profile:write"})
|
||||||
|
with patch(
|
||||||
|
"govoplan_mail.backend.documentation.effective_mail_profile_policy_for_scope",
|
||||||
|
side_effect=MailProfileError("sensitive source-id profile-id username secret"),
|
||||||
|
):
|
||||||
|
topics = {topic.id: topic for topic in documentation_topics(context)}
|
||||||
|
|
||||||
|
self.assertNotIn("mail.workflow.create-custom-profile", topics)
|
||||||
|
unavailable = topics["mail.tenant-profile-policy-unavailable"]
|
||||||
|
self.assertEqual(
|
||||||
|
unavailable.body,
|
||||||
|
"The current Mail policy could not be loaded. Try again or ask a Mail administrator for help.",
|
||||||
|
)
|
||||||
|
self.assertNotIn("sensitive", repr(unavailable))
|
||||||
|
|
||||||
|
def test_manifest_workflows_declare_contextual_help_targets(self) -> None:
|
||||||
|
from govoplan_mail.backend.manifest import get_manifest
|
||||||
|
|
||||||
|
topics = {topic.id: topic for topic in get_manifest().documentation}
|
||||||
|
self.assertEqual(topics["mail.workflow.choose-and-test-profile"].metadata["help_contexts"], ["mail.profiles", "app.settings"])
|
||||||
|
self.assertEqual(topics["mail.workflow.read-mailbox"].metadata["help_contexts"], ["mail.list"])
|
||||||
|
|
||||||
|
def test_configuration_provider_exposes_only_explicit_or_inherited_state(self) -> None:
|
||||||
|
inherited = EffectiveMailProfilePolicy(
|
||||||
|
source_policies=[
|
||||||
|
{"scope_type": "system", "applied_fields": ["defaults"]},
|
||||||
|
{"scope_type": "tenant", "applied_fields": []},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
explicit = EffectiveMailProfilePolicy(
|
||||||
|
source_policies=[
|
||||||
|
{"scope_type": "system", "applied_fields": ["defaults"]},
|
||||||
|
{"scope_type": "tenant", "applied_fields": ["smtp_hosts"]},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
context = self.context({"mail:profile:read"}, documentation_type="admin")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_mail.backend.documentation.effective_mail_profile_policy_for_scope",
|
||||||
|
return_value=inherited,
|
||||||
|
):
|
||||||
|
inherited_state = documentation_configuration_states(
|
||||||
|
context,
|
||||||
|
("mail_profile_policy",),
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"govoplan_mail.backend.documentation.effective_mail_profile_policy_for_scope",
|
||||||
|
return_value=explicit,
|
||||||
|
):
|
||||||
|
explicit_state = documentation_configuration_states(
|
||||||
|
context,
|
||||||
|
("mail_profile_policy",),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
inherited_state["mail_profile_policy"].state,
|
||||||
|
"inherited",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
explicit_state["mail_profile_policy"].state,
|
||||||
|
"enabled",
|
||||||
|
)
|
||||||
|
self.assertNotIn("smtp_hosts", repr(explicit_state))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -40,6 +40,21 @@ class MailManifestTests(unittest.TestCase):
|
|||||||
for interface in manifest.provides_interfaces
|
for interface in manifest.provides_interfaces
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
permissions = {permission.scope for permission in manifest.permissions}
|
||||||
|
self.assertIn("mail:profile:write_own", permissions)
|
||||||
|
self.assertIn("mail:secret:manage_own", permissions)
|
||||||
|
roles = {template.slug: template for template in manifest.role_templates}
|
||||||
|
self.assertEqual(
|
||||||
|
set(roles["mail_profile_self_service"].permissions),
|
||||||
|
{
|
||||||
|
"mail:profile:read",
|
||||||
|
"mail:profile:use",
|
||||||
|
"mail:profile:test",
|
||||||
|
"mail:mailbox:read",
|
||||||
|
"mail:profile:write_own",
|
||||||
|
"mail:secret:manage_own",
|
||||||
|
},
|
||||||
|
)
|
||||||
topics = {topic.id: topic for topic in manifest.documentation}
|
topics = {topic.id: topic for topic in manifest.documentation}
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
{
|
{
|
||||||
|
|||||||
91
tests/test_notification_delivery_capability.py
Normal file
91
tests/test_notification_delivery_capability.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from email import message_from_bytes
|
||||||
|
from unittest.mock import ANY, patch
|
||||||
|
|
||||||
|
from govoplan_core.core.mail import NotificationMailDeliveryRequest
|
||||||
|
from govoplan_mail.backend.capabilities import MailNotificationDeliveryCapability
|
||||||
|
|
||||||
|
|
||||||
|
class MailNotificationDeliveryCapabilityTests(unittest.TestCase):
|
||||||
|
def test_missing_policy_selection_pauses_without_transport_effect(self) -> None:
|
||||||
|
capability = MailNotificationDeliveryCapability()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_mail.backend.delivery_outbox.submit_delivery_command"
|
||||||
|
) as submit:
|
||||||
|
result = capability.submit_notification_mail(
|
||||||
|
object(),
|
||||||
|
NotificationMailDeliveryRequest(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
notification_id="notification-1",
|
||||||
|
recipient="person@example.test",
|
||||||
|
subject="Subject",
|
||||||
|
body_text="Body",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "paused")
|
||||||
|
submit.assert_not_called()
|
||||||
|
|
||||||
|
def test_notification_is_submitted_to_durable_mail_outbox(self) -> None:
|
||||||
|
capability = MailNotificationDeliveryCapability()
|
||||||
|
transport = {
|
||||||
|
"smtp_available": True,
|
||||||
|
"smtp_transport_revision": "revision-1",
|
||||||
|
"smtp_server_id": "server-1",
|
||||||
|
"smtp_credential_id": "credential-1",
|
||||||
|
}
|
||||||
|
command = {
|
||||||
|
"id": "command-1",
|
||||||
|
"status": "pending",
|
||||||
|
"duplicate": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_mail.backend.capabilities.campaign_profile_delivery_summary",
|
||||||
|
return_value=transport,
|
||||||
|
) as summary,
|
||||||
|
patch(
|
||||||
|
"govoplan_mail.backend.delivery_outbox.submit_delivery_command",
|
||||||
|
return_value=command,
|
||||||
|
) as submit,
|
||||||
|
):
|
||||||
|
result = capability.submit_notification_mail(
|
||||||
|
object(),
|
||||||
|
NotificationMailDeliveryRequest(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
notification_id="notification-1",
|
||||||
|
recipient="person@example.test",
|
||||||
|
subject="Subject",
|
||||||
|
body_text="Body",
|
||||||
|
body_html="<p>Body</p>",
|
||||||
|
mail_profile_id="profile-1",
|
||||||
|
from_address="notifications@example.test",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "accepted")
|
||||||
|
self.assertEqual(result["external_message_id"], "command-1")
|
||||||
|
summary.assert_called_once_with(
|
||||||
|
ANY,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
profile_id="profile-1",
|
||||||
|
smtp_server_id=None,
|
||||||
|
smtp_credential_id=None,
|
||||||
|
)
|
||||||
|
payload = submit.call_args.kwargs
|
||||||
|
self.assertEqual(payload["idempotency_key"], "notification:notification-1")
|
||||||
|
self.assertEqual(payload["profile_id"], "profile-1")
|
||||||
|
self.assertEqual(payload["smtp_server_id"], "server-1")
|
||||||
|
self.assertEqual(payload["smtp_credential_id"], "credential-1")
|
||||||
|
message = message_from_bytes(payload["message_bytes"])
|
||||||
|
self.assertEqual(message["From"], "notifications@example.test")
|
||||||
|
self.assertEqual(message["To"], "person@example.test")
|
||||||
|
self.assertEqual(message["Subject"], "Subject")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -2,14 +2,17 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import ANY, patch
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
from govoplan_mail.backend import router
|
from govoplan_mail.backend import router
|
||||||
|
from govoplan_mail.backend import mail_profiles
|
||||||
from govoplan_mail.backend.mail_profiles import (
|
from govoplan_mail.backend.mail_profiles import (
|
||||||
EffectiveMailProfilePolicy,
|
EffectiveMailProfilePolicy,
|
||||||
MailProfileError,
|
MailProfileError,
|
||||||
|
get_mail_server_profile_for_actor,
|
||||||
list_mail_server_profiles,
|
list_mail_server_profiles,
|
||||||
mail_profile_visible_to_actor,
|
mail_profile_visible_to_actor,
|
||||||
)
|
)
|
||||||
@@ -35,6 +38,8 @@ def _profile(
|
|||||||
name=profile_id,
|
name=profile_id,
|
||||||
smtp_config={"host": "smtp.example.test"},
|
smtp_config={"host": "smtp.example.test"},
|
||||||
imap_config={"host": "imap.example.test"},
|
imap_config={"host": "imap.example.test"},
|
||||||
|
smtp_password_encrypted=None,
|
||||||
|
imap_password_encrypted=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -67,11 +72,15 @@ class _Session:
|
|||||||
def rollback(self):
|
def rollback(self):
|
||||||
self.rollbacks += 1
|
self.rollbacks += 1
|
||||||
|
|
||||||
|
def refresh(self, _value):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class _Principal:
|
class _Principal:
|
||||||
tenant_id = "tenant-1"
|
tenant_id = "tenant-1"
|
||||||
user = SimpleNamespace(id="user-1")
|
user = SimpleNamespace(id="user-1")
|
||||||
group_ids = frozenset({"group-1"})
|
group_ids = frozenset({"group-1"})
|
||||||
|
api_key_id = None
|
||||||
|
|
||||||
def __init__(self, scopes):
|
def __init__(self, scopes):
|
||||||
self._scopes = frozenset(scopes)
|
self._scopes = frozenset(scopes)
|
||||||
@@ -81,6 +90,375 @@ class _Principal:
|
|||||||
|
|
||||||
|
|
||||||
class ProfileActorAuthorizationTests(unittest.TestCase):
|
class ProfileActorAuthorizationTests(unittest.TestCase):
|
||||||
|
def test_profile_mutation_selector_is_owner_bounded_and_row_locked(self) -> None:
|
||||||
|
statement = mail_profiles._mail_server_profile_mutation_statement( # noqa: SLF001
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
profile_id="profile-1",
|
||||||
|
owner_user_id="user-1",
|
||||||
|
can_manage_tenant_profiles=False,
|
||||||
|
can_manage_system_profiles=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
sql = str(statement.compile(dialect=postgresql.dialect()))
|
||||||
|
self.assertIn("FOR UPDATE", sql)
|
||||||
|
self.assertIn("mail_server_profiles.tenant_id =", sql)
|
||||||
|
self.assertIn("mail_server_profiles.scope_type =", sql)
|
||||||
|
self.assertIn("mail_server_profiles.scope_id =", sql)
|
||||||
|
self.assertTrue(statement.get_execution_options()["populate_existing"])
|
||||||
|
|
||||||
|
def test_profile_mutation_selector_without_authority_cannot_lock_a_row(self) -> None:
|
||||||
|
statement = mail_profiles._mail_server_profile_mutation_statement( # noqa: SLF001
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
profile_id="profile-1",
|
||||||
|
owner_user_id=None,
|
||||||
|
can_manage_tenant_profiles=False,
|
||||||
|
can_manage_system_profiles=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
sql = str(statement.compile(dialect=postgresql.dialect()))
|
||||||
|
self.assertIn("mail_server_profiles.id IS NULL", sql)
|
||||||
|
self.assertIn("FOR UPDATE", sql)
|
||||||
|
|
||||||
|
def test_self_service_profile_permissions_are_limited_to_own_user_scope(self) -> None:
|
||||||
|
principal = _Principal(
|
||||||
|
{"mail:profile:write_own", "mail:secret:manage_own"}
|
||||||
|
)
|
||||||
|
|
||||||
|
router._require_profile_write_scope( # noqa: SLF001 - authorization seam
|
||||||
|
principal, # type: ignore[arg-type]
|
||||||
|
"user",
|
||||||
|
"user-1",
|
||||||
|
)
|
||||||
|
router._require_profile_credentials_scope( # noqa: SLF001 - authorization seam
|
||||||
|
principal, # type: ignore[arg-type]
|
||||||
|
"user",
|
||||||
|
"user-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
for scope_type, scope_id in (
|
||||||
|
("user", "user-2"),
|
||||||
|
("tenant", "tenant-1"),
|
||||||
|
("group", "group-1"),
|
||||||
|
("campaign", "campaign-1"),
|
||||||
|
("system", None),
|
||||||
|
):
|
||||||
|
with self.subTest(scope_type=scope_type, scope_id=scope_id):
|
||||||
|
with self.assertRaises(HTTPException) as write_denied:
|
||||||
|
router._require_profile_write_scope( # noqa: SLF001
|
||||||
|
principal, # type: ignore[arg-type]
|
||||||
|
scope_type,
|
||||||
|
scope_id,
|
||||||
|
)
|
||||||
|
self.assertEqual(write_denied.exception.status_code, 403)
|
||||||
|
|
||||||
|
with self.assertRaises(HTTPException) as secret_denied:
|
||||||
|
router._require_profile_credentials_scope( # noqa: SLF001
|
||||||
|
principal, # type: ignore[arg-type]
|
||||||
|
scope_type,
|
||||||
|
scope_id,
|
||||||
|
)
|
||||||
|
self.assertEqual(secret_denied.exception.status_code, 403)
|
||||||
|
|
||||||
|
def test_self_service_create_rejects_another_user_before_persistence(self) -> None:
|
||||||
|
principal = _Principal(
|
||||||
|
{
|
||||||
|
"mail:profile:read",
|
||||||
|
"mail:profile:write_own",
|
||||||
|
"mail:secret:manage_own",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
payload = MailServerProfileCreateRequest.model_validate(
|
||||||
|
{
|
||||||
|
"name": "Other user's profile",
|
||||||
|
"scope_type": "user",
|
||||||
|
"scope_id": "user-2",
|
||||||
|
"smtp": {
|
||||||
|
"host": "smtp.example.test",
|
||||||
|
"password": "not-persisted",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch("govoplan_mail.backend.router.create_mail_server_profile") as create,
|
||||||
|
self.assertRaises(HTTPException) as denied,
|
||||||
|
):
|
||||||
|
router.create_profile(
|
||||||
|
payload,
|
||||||
|
principal=principal, # type: ignore[arg-type]
|
||||||
|
session=_Session(), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(denied.exception.status_code, 403)
|
||||||
|
create.assert_not_called()
|
||||||
|
|
||||||
|
def test_self_service_update_hides_another_user_before_mutation(self) -> None:
|
||||||
|
principal = _Principal(
|
||||||
|
{"mail:profile:write_own", "mail:secret:manage_own"}
|
||||||
|
)
|
||||||
|
profile = _profile(
|
||||||
|
"other-user-profile",
|
||||||
|
scope_type="user",
|
||||||
|
scope_id="user-2",
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_mail.backend.router.get_mail_server_profile",
|
||||||
|
return_value=profile,
|
||||||
|
),
|
||||||
|
patch("govoplan_mail.backend.router.update_mail_server_profile") as update,
|
||||||
|
self.assertRaises(HTTPException) as denied,
|
||||||
|
):
|
||||||
|
router.update_profile(
|
||||||
|
profile.id,
|
||||||
|
MailServerProfileUpdateRequest(name="Must not change"),
|
||||||
|
principal=principal, # type: ignore[arg-type]
|
||||||
|
session=_Session(), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(denied.exception.status_code, 404)
|
||||||
|
update.assert_not_called()
|
||||||
|
|
||||||
|
def test_self_service_delete_hides_another_user_before_mutation(self) -> None:
|
||||||
|
principal = _Principal(
|
||||||
|
{"mail:profile:write_own", "mail:secret:manage_own"}
|
||||||
|
)
|
||||||
|
profile = _profile(
|
||||||
|
"other-user-profile",
|
||||||
|
scope_type="user",
|
||||||
|
scope_id="user-2",
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_mail.backend.router.get_mail_server_profile",
|
||||||
|
return_value=profile,
|
||||||
|
),
|
||||||
|
patch("govoplan_mail.backend.router.delete_mail_profile_credentials") as delete,
|
||||||
|
self.assertRaises(HTTPException) as denied,
|
||||||
|
):
|
||||||
|
router.deactivate_profile(
|
||||||
|
profile.id,
|
||||||
|
principal=principal, # type: ignore[arg-type]
|
||||||
|
session=_Session(), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(denied.exception.status_code, 404)
|
||||||
|
delete.assert_not_called()
|
||||||
|
|
||||||
|
def test_mutation_lookup_returns_the_same_not_found_for_missing_and_non_owned(self) -> None:
|
||||||
|
principal = _Principal({"mail:profile:write_own"})
|
||||||
|
other_profile = _profile(
|
||||||
|
"other-user-profile",
|
||||||
|
scope_type="user",
|
||||||
|
scope_id="user-2",
|
||||||
|
)
|
||||||
|
cases = (
|
||||||
|
(other_profile, None),
|
||||||
|
(None, MailProfileError("Mail-server profile not found")),
|
||||||
|
)
|
||||||
|
for returned, failure in cases:
|
||||||
|
with self.subTest(failure=failure is not None):
|
||||||
|
kwargs = {"side_effect": failure} if failure else {"return_value": returned}
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_mail.backend.router.get_mail_server_profile",
|
||||||
|
**kwargs,
|
||||||
|
),
|
||||||
|
self.assertRaises(HTTPException) as denied,
|
||||||
|
):
|
||||||
|
router._profile_for_mutation( # noqa: SLF001 - authorization seam
|
||||||
|
_Session(), # type: ignore[arg-type]
|
||||||
|
principal=principal, # type: ignore[arg-type]
|
||||||
|
profile_id="candidate-id",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(denied.exception.status_code, 404)
|
||||||
|
self.assertEqual(denied.exception.detail, "Mail-server profile not found")
|
||||||
|
|
||||||
|
def test_broad_profile_admin_retains_cross_owner_mutation_access(self) -> None:
|
||||||
|
principal = _Principal({"mail:profile:write"})
|
||||||
|
profile = _profile(
|
||||||
|
"other-user-profile",
|
||||||
|
scope_type="user",
|
||||||
|
scope_id="user-2",
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"govoplan_mail.backend.router.get_mail_server_profile",
|
||||||
|
return_value=profile,
|
||||||
|
) as get_profile:
|
||||||
|
resolved = router._profile_for_mutation( # noqa: SLF001 - authorization seam
|
||||||
|
_Session(), # type: ignore[arg-type]
|
||||||
|
principal=principal, # type: ignore[arg-type]
|
||||||
|
profile_id=profile.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIs(resolved, profile)
|
||||||
|
get_profile.assert_called_once_with(
|
||||||
|
ANY,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
profile_id=profile.id,
|
||||||
|
for_update=True,
|
||||||
|
mutation_owner_user_id=None,
|
||||||
|
can_manage_tenant_profiles=True,
|
||||||
|
can_manage_system_profiles=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_self_service_transport_rebind_requires_own_secret_authority(self) -> None:
|
||||||
|
principal = _Principal({"mail:profile:write_own"})
|
||||||
|
profile = _profile(
|
||||||
|
"own-user-profile",
|
||||||
|
scope_type="user",
|
||||||
|
scope_id="user-1",
|
||||||
|
)
|
||||||
|
profile.smtp_config = {
|
||||||
|
"host": "smtp.example.test",
|
||||||
|
"port": 587,
|
||||||
|
"security": "starttls",
|
||||||
|
"timeout_seconds": 30,
|
||||||
|
}
|
||||||
|
profile.smtp_password_encrypted = "existing-ciphertext"
|
||||||
|
payload = MailServerProfileUpdateRequest.model_validate(
|
||||||
|
{"smtp": {"host": "smtp.attacker.test"}}
|
||||||
|
)
|
||||||
|
session = _Session()
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_mail.backend.router.get_mail_server_profile",
|
||||||
|
return_value=profile,
|
||||||
|
),
|
||||||
|
patch("govoplan_mail.backend.router.update_mail_server_profile") as update,
|
||||||
|
self.assertRaises(HTTPException) as denied,
|
||||||
|
):
|
||||||
|
router.update_profile(
|
||||||
|
profile.id,
|
||||||
|
payload,
|
||||||
|
principal=principal, # type: ignore[arg-type]
|
||||||
|
session=session, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(denied.exception.status_code, 403)
|
||||||
|
self.assertEqual(session.rollbacks, 1)
|
||||||
|
update.assert_not_called()
|
||||||
|
|
||||||
|
def test_self_service_imap_rebind_requires_own_secret_authority(self) -> None:
|
||||||
|
principal = _Principal({"mail:profile:write_own"})
|
||||||
|
profile = _profile(
|
||||||
|
"own-user-profile",
|
||||||
|
scope_type="user",
|
||||||
|
scope_id="user-1",
|
||||||
|
)
|
||||||
|
profile.imap_config = {
|
||||||
|
"host": "imap.example.test",
|
||||||
|
"port": 993,
|
||||||
|
"security": "tls",
|
||||||
|
"sent_folder": "auto",
|
||||||
|
"timeout_seconds": 30,
|
||||||
|
}
|
||||||
|
profile.imap_password_encrypted = "existing-ciphertext"
|
||||||
|
payload = MailServerProfileUpdateRequest.model_validate(
|
||||||
|
{"imap": {"host": "imap.attacker.test"}}
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_mail.backend.router.get_mail_server_profile",
|
||||||
|
return_value=profile,
|
||||||
|
),
|
||||||
|
patch("govoplan_mail.backend.router.update_mail_server_profile") as update,
|
||||||
|
self.assertRaises(HTTPException) as denied,
|
||||||
|
):
|
||||||
|
router.update_profile(
|
||||||
|
profile.id,
|
||||||
|
payload,
|
||||||
|
principal=principal, # type: ignore[arg-type]
|
||||||
|
session=_Session(), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(denied.exception.status_code, 403)
|
||||||
|
update.assert_not_called()
|
||||||
|
|
||||||
|
def test_self_service_transport_rebind_is_allowed_with_own_secret_authority(self) -> None:
|
||||||
|
principal = _Principal(
|
||||||
|
{"mail:profile:write_own", "mail:secret:manage_own"}
|
||||||
|
)
|
||||||
|
profile = _profile(
|
||||||
|
"own-user-profile",
|
||||||
|
scope_type="user",
|
||||||
|
scope_id="user-1",
|
||||||
|
)
|
||||||
|
profile.smtp_config = {
|
||||||
|
"host": "smtp.example.test",
|
||||||
|
"port": 587,
|
||||||
|
"security": "starttls",
|
||||||
|
"timeout_seconds": 30,
|
||||||
|
}
|
||||||
|
profile.smtp_password_encrypted = "existing-ciphertext"
|
||||||
|
payload = MailServerProfileUpdateRequest.model_validate(
|
||||||
|
{"smtp": {"host": "smtp.allowed.test"}}
|
||||||
|
)
|
||||||
|
session = _Session()
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_mail.backend.router.get_mail_server_profile",
|
||||||
|
return_value=profile,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_mail.backend.router.update_mail_server_profile",
|
||||||
|
return_value=profile,
|
||||||
|
) as update,
|
||||||
|
patch("govoplan_mail.backend.router._record_mail_change"),
|
||||||
|
patch(
|
||||||
|
"govoplan_mail.backend.router._profile_response",
|
||||||
|
return_value=profile,
|
||||||
|
),
|
||||||
|
patch("govoplan_mail.backend.router.sync_default_profile_server"),
|
||||||
|
):
|
||||||
|
result = router.update_profile(
|
||||||
|
profile.id,
|
||||||
|
payload,
|
||||||
|
principal=principal, # type: ignore[arg-type]
|
||||||
|
session=session, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIs(result, profile)
|
||||||
|
self.assertEqual(session.commits, 1)
|
||||||
|
update.assert_called_once()
|
||||||
|
|
||||||
|
def test_transport_endpoint_change_without_stored_secret_needs_only_write_authority(self) -> None:
|
||||||
|
principal = _Principal({"mail:profile:write_own"})
|
||||||
|
profile = _profile(
|
||||||
|
"own-user-profile",
|
||||||
|
scope_type="user",
|
||||||
|
scope_id="user-1",
|
||||||
|
)
|
||||||
|
payload = MailServerProfileUpdateRequest.model_validate(
|
||||||
|
{"smtp": {"host": "smtp.allowed.test"}}
|
||||||
|
)
|
||||||
|
session = _Session()
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_mail.backend.router.get_mail_server_profile",
|
||||||
|
return_value=profile,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_mail.backend.router.update_mail_server_profile",
|
||||||
|
return_value=profile,
|
||||||
|
) as update,
|
||||||
|
patch("govoplan_mail.backend.router._record_mail_change"),
|
||||||
|
patch(
|
||||||
|
"govoplan_mail.backend.router._profile_response",
|
||||||
|
return_value=profile,
|
||||||
|
),
|
||||||
|
patch("govoplan_mail.backend.router.sync_default_profile_server"),
|
||||||
|
):
|
||||||
|
router.update_profile(
|
||||||
|
profile.id,
|
||||||
|
payload,
|
||||||
|
principal=principal, # type: ignore[arg-type]
|
||||||
|
session=session, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(session.commits, 1)
|
||||||
|
update.assert_called_once()
|
||||||
|
|
||||||
def test_general_profile_list_hides_other_owner_contexts(self) -> None:
|
def test_general_profile_list_hides_other_owner_contexts(self) -> None:
|
||||||
profiles = [
|
profiles = [
|
||||||
_profile("system", scope_type="system", scope_id=None, tenant_id=None),
|
_profile("system", scope_type="system", scope_id=None, tenant_id=None),
|
||||||
@@ -142,6 +520,63 @@ class ProfileActorAuthorizationTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual([item.id for item in visible], ["inactive"])
|
self.assertEqual([item.id for item in visible], ["inactive"])
|
||||||
|
|
||||||
|
def test_self_service_repair_visibility_is_limited_to_the_exact_owner(self) -> None:
|
||||||
|
own_profile = _profile(
|
||||||
|
"own-policy-invalid",
|
||||||
|
scope_type="user",
|
||||||
|
scope_id="user-1",
|
||||||
|
)
|
||||||
|
other_profile = _profile(
|
||||||
|
"other-policy-invalid",
|
||||||
|
scope_type="user",
|
||||||
|
scope_id="user-2",
|
||||||
|
)
|
||||||
|
denied = EffectiveMailProfilePolicy(
|
||||||
|
allowed_profile_id_sets=[{"different-profile"}]
|
||||||
|
)
|
||||||
|
session = _Session([own_profile, other_profile])
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_mail.backend.mail_profiles.effective_mail_profile_policy",
|
||||||
|
return_value=denied,
|
||||||
|
):
|
||||||
|
ordinary_visible = list_mail_server_profiles(
|
||||||
|
session, # type: ignore[arg-type]
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
include_inactive=True,
|
||||||
|
actor_user_id="user-1",
|
||||||
|
actor_administrative_visibility=True,
|
||||||
|
)
|
||||||
|
repair_visible = list_mail_server_profiles(
|
||||||
|
session, # type: ignore[arg-type]
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
include_inactive=True,
|
||||||
|
actor_user_id="user-1",
|
||||||
|
actor_can_manage_own_profiles=True,
|
||||||
|
actor_administrative_visibility=True,
|
||||||
|
)
|
||||||
|
resolved = get_mail_server_profile_for_actor(
|
||||||
|
session, # type: ignore[arg-type]
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
profile_id=own_profile.id,
|
||||||
|
user_id="user-1",
|
||||||
|
can_manage_own_profiles=True,
|
||||||
|
administrative_visibility=True,
|
||||||
|
)
|
||||||
|
with self.assertRaises(MailProfileError):
|
||||||
|
get_mail_server_profile_for_actor(
|
||||||
|
session, # type: ignore[arg-type]
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
profile_id=other_profile.id,
|
||||||
|
user_id="user-1",
|
||||||
|
can_manage_own_profiles=True,
|
||||||
|
administrative_visibility=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(ordinary_visible, [])
|
||||||
|
self.assertEqual([item.id for item in repair_visible], [own_profile.id])
|
||||||
|
self.assertIs(resolved, own_profile)
|
||||||
|
|
||||||
def test_profile_admin_can_list_but_not_use_a_policy_denied_profile(self) -> None:
|
def test_profile_admin_can_list_but_not_use_a_policy_denied_profile(self) -> None:
|
||||||
profile = _profile("denied", scope_type="tenant", scope_id="tenant-1")
|
profile = _profile("denied", scope_type="tenant", scope_id="tenant-1")
|
||||||
session = _Session([profile])
|
session = _Session([profile])
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class MailProfileDeletionRouteTests(unittest.TestCase):
|
|||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
user=SimpleNamespace(id="user-1"),
|
user=SimpleNamespace(id="user-1"),
|
||||||
api_key_id=None,
|
api_key_id=None,
|
||||||
|
has=lambda _scope: False,
|
||||||
)
|
)
|
||||||
self.profile = SimpleNamespace(
|
self.profile = SimpleNamespace(
|
||||||
id="profile-1",
|
id="profile-1",
|
||||||
@@ -48,7 +49,7 @@ class MailProfileDeletionRouteTests(unittest.TestCase):
|
|||||||
with (
|
with (
|
||||||
patch("govoplan_mail.backend.router.get_mail_server_profile", return_value=self.profile),
|
patch("govoplan_mail.backend.router.get_mail_server_profile", return_value=self.profile),
|
||||||
patch("govoplan_mail.backend.router._require_profile_write_scope"),
|
patch("govoplan_mail.backend.router._require_profile_write_scope"),
|
||||||
patch("govoplan_mail.backend.router._require_profile_credentials_scope"),
|
patch("govoplan_mail.backend.router._require_profile_credentials_scope") as require_credentials,
|
||||||
patch("govoplan_mail.backend.router.delete_mail_profile_credentials", return_value=()),
|
patch("govoplan_mail.backend.router.delete_mail_profile_credentials", return_value=()),
|
||||||
patch("govoplan_mail.backend.router.clear_mailbox_index") as clear_index,
|
patch("govoplan_mail.backend.router.clear_mailbox_index") as clear_index,
|
||||||
patch("govoplan_mail.backend.router._record_mail_change") as record_change,
|
patch("govoplan_mail.backend.router._record_mail_change") as record_change,
|
||||||
@@ -61,6 +62,32 @@ class MailProfileDeletionRouteTests(unittest.TestCase):
|
|||||||
self.assertEqual(session.rollbacks, 0)
|
self.assertEqual(session.rollbacks, 0)
|
||||||
clear_index.assert_called_once_with(session, profile_id="profile-1")
|
clear_index.assert_called_once_with(session, profile_id="profile-1")
|
||||||
record_change.assert_not_called()
|
record_change.assert_not_called()
|
||||||
|
require_credentials.assert_not_called()
|
||||||
|
|
||||||
|
def test_delete_requires_secret_authority_when_credentials_will_be_deleted(self) -> None:
|
||||||
|
self.profile.is_active = True
|
||||||
|
self.profile.smtp_password_encrypted = "existing-ciphertext"
|
||||||
|
self.profile.imap_password_encrypted = None
|
||||||
|
session = _Session()
|
||||||
|
with (
|
||||||
|
patch("govoplan_mail.backend.router.get_mail_server_profile", return_value=self.profile),
|
||||||
|
patch("govoplan_mail.backend.router._require_profile_write_scope"),
|
||||||
|
patch("govoplan_mail.backend.router._require_profile_credentials_scope") as require_credentials,
|
||||||
|
patch(
|
||||||
|
"govoplan_mail.backend.router.delete_mail_profile_credentials",
|
||||||
|
return_value=("smtp",),
|
||||||
|
),
|
||||||
|
patch("govoplan_mail.backend.router.clear_mailbox_index"),
|
||||||
|
patch("govoplan_mail.backend.router._record_mail_change"),
|
||||||
|
patch("govoplan_mail.backend.router._profile_response", return_value=self.profile),
|
||||||
|
):
|
||||||
|
deactivate_profile("profile-1", principal=self.principal, session=session)
|
||||||
|
|
||||||
|
require_credentials.assert_called_once_with(
|
||||||
|
self.principal,
|
||||||
|
"tenant",
|
||||||
|
"tenant-1",
|
||||||
|
)
|
||||||
|
|
||||||
def test_change_feed_reports_whether_credentials_were_deleted(self) -> None:
|
def test_change_feed_reports_whether_credentials_were_deleted(self) -> None:
|
||||||
self.profile.is_active = True
|
self.profile.is_active = True
|
||||||
@@ -139,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),
|
||||||
):
|
):
|
||||||
|
|||||||
22
webui/package-lock.json
generated
22
webui/package-lock.json
generated
@@ -1,21 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/mail-webui",
|
"name": "@govoplan/mail-webui",
|
||||||
"version": "0.1.9",
|
"version": "0.1.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@govoplan/mail-webui",
|
"name": "@govoplan/mail-webui",
|
||||||
"version": "0.1.9",
|
"version": "0.1.10",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.9",
|
"@govoplan/core-webui": "^0.1.10",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1"
|
"react-router-dom": ">=7.18.2 <8"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
@@ -71,9 +71,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.18.1",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
|
||||||
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
|
"integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -94,13 +94,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "7.18.1",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
|
||||||
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
|
"integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-router": "7.18.1"
|
"react-router": "7.18.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/mail-webui",
|
"name": "@govoplan/mail-webui",
|
||||||
"version": "0.1.9",
|
"version": "0.1.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -14,11 +14,11 @@
|
|||||||
"./styles/mail-profiles.css": "./src/styles/mail-profiles.css"
|
"./styles/mail-profiles.css": "./src/styles/mail-profiles.css"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.9",
|
"@govoplan/core-webui": "^0.1.10",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1"
|
"react-router-dom": ">=7.18.2 <8"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -23,6 +23,22 @@ export type MailProfileChildDescriptor = {
|
|||||||
protocol: MailProfileProtocol;
|
protocol: MailProfileProtocol;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MailProfileTransportCredentialsLike = {
|
||||||
|
username?: string | null;
|
||||||
|
password?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailProfileTargetedUpdateParts = {
|
||||||
|
profile: Record<string, unknown>;
|
||||||
|
smtp: Record<string, unknown>;
|
||||||
|
imap: Record<string, unknown> | null;
|
||||||
|
credentials: {
|
||||||
|
smtp: MailProfileTransportCredentialsLike;
|
||||||
|
imap: MailProfileTransportCredentialsLike;
|
||||||
|
};
|
||||||
|
clearImap: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export function mailProfileChildDescriptors(profile: MailProfileTreeProfileLike): MailProfileChildDescriptor[] {
|
export function mailProfileChildDescriptors(profile: MailProfileTreeProfileLike): MailProfileChildDescriptor[] {
|
||||||
const children: MailProfileChildDescriptor[] = [
|
const children: MailProfileChildDescriptor[] = [
|
||||||
{ kind: "server", id: `server:${profile.id}:smtp`, protocol: "smtp" },
|
{ kind: "server", id: `server:${profile.id}:smtp`, protocol: "smtp" },
|
||||||
@@ -58,3 +74,42 @@ export function mailProfileEditTargetShowsProfileFields(target: MailProfileEditT
|
|||||||
export function mailProfileEditTargetShowsSettingsPanel(target: MailProfileEditTarget): boolean {
|
export function mailProfileEditTargetShowsSettingsPanel(target: MailProfileEditTarget): boolean {
|
||||||
return target.kind !== "profile";
|
return target.kind !== "profile";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the PATCH authority surface equal to the focused editor. Profile and
|
||||||
|
* server edits must not accidentally replay credential fields merely because
|
||||||
|
* the form draft contains their displayed values.
|
||||||
|
*/
|
||||||
|
export function mailProfileTargetedUpdatePayload(
|
||||||
|
target: MailProfileEditTarget,
|
||||||
|
parts: MailProfileTargetedUpdateParts
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (target.kind === "profile") return parts.profile;
|
||||||
|
if (target.kind === "server") {
|
||||||
|
if (target.protocol === "smtp") return { smtp: parts.smtp };
|
||||||
|
return parts.imap === null
|
||||||
|
? { imap: null, clear_imap: parts.clearImap }
|
||||||
|
: { imap: parts.imap };
|
||||||
|
}
|
||||||
|
if (target.kind === "credentials") {
|
||||||
|
return { credentials: { [target.protocol]: parts.credentials[target.protocol] } };
|
||||||
|
}
|
||||||
|
throw new Error("Create is not an update target");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Omit blank create credentials so profile-write remains independent. */
|
||||||
|
export function mailProfileCreateCredentialsPayload(
|
||||||
|
smtp: MailProfileTransportCredentialsLike,
|
||||||
|
imap: MailProfileTransportCredentialsLike
|
||||||
|
): {smtp?: MailProfileTransportCredentialsLike;imap?: MailProfileTransportCredentialsLike;} | undefined {
|
||||||
|
const populated = (value: MailProfileTransportCredentialsLike): MailProfileTransportCredentialsLike =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(value).filter(([, item]) => item !== null && item !== undefined && item !== "")
|
||||||
|
);
|
||||||
|
const smtpCredentials = populated(smtp);
|
||||||
|
const imapCredentials = populated(imap);
|
||||||
|
const result: {smtp?: MailProfileTransportCredentialsLike;imap?: MailProfileTransportCredentialsLike;} = {};
|
||||||
|
if (Object.keys(smtpCredentials).length > 0) result.smtp = smtpCredentials;
|
||||||
|
if (Object.keys(imapCredentials).length > 0) result.imap = imapCredentials;
|
||||||
|
return Object.keys(result).length > 0 ? result : undefined;
|
||||||
|
}
|
||||||
|
|||||||
144
webui/src/features/mail/mailReferenceProviders.ts
Normal file
144
webui/src/features/mail/mailReferenceProviders.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import {
|
||||||
|
filterSearchableSelectOptions,
|
||||||
|
unavailableReferenceOption,
|
||||||
|
type ApiSettings,
|
||||||
|
type CredentialReferenceSelectorContext,
|
||||||
|
type CredentialReferenceSelectorsUiCapability,
|
||||||
|
type ReferenceOption,
|
||||||
|
type ReferenceOptionProvider
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
fetchMailSettingsDelta,
|
||||||
|
type MailServerProfile
|
||||||
|
} from "../../api/mail";
|
||||||
|
|
||||||
|
export const mailCredentialReferenceSelectors:
|
||||||
|
CredentialReferenceSelectorsUiCapability = {
|
||||||
|
serverProvider: createMailServerReferenceProvider
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createMailServerReferenceProvider(
|
||||||
|
settings: ApiSettings,
|
||||||
|
context: CredentialReferenceSelectorContext
|
||||||
|
): ReferenceOptionProvider {
|
||||||
|
let cataloguePromise: Promise<ReferenceOption[]> | null = null;
|
||||||
|
|
||||||
|
async function catalogue(signal: AbortSignal): Promise<ReferenceOption[]> {
|
||||||
|
cataloguePromise ??= loadProfiles(settings, context, signal)
|
||||||
|
.then(mailServerOptions)
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
cataloguePromise = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
const options = await cataloguePromise;
|
||||||
|
if (signal.aborted) throw abortError();
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async search(query, providerContext) {
|
||||||
|
const options = await catalogue(providerContext.signal);
|
||||||
|
return filterSearchableSelectOptions(
|
||||||
|
options,
|
||||||
|
query,
|
||||||
|
providerContext.limit
|
||||||
|
) as ReferenceOption[];
|
||||||
|
},
|
||||||
|
async resolve(values, providerContext) {
|
||||||
|
const options = await catalogue(providerContext.signal);
|
||||||
|
const byValue = new Map(
|
||||||
|
options.map((option) => [option.value, option])
|
||||||
|
);
|
||||||
|
return values.map(
|
||||||
|
(value) =>
|
||||||
|
byValue.get(value)
|
||||||
|
?? unavailableReferenceOption(value, "Unavailable mail server")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mailServerOptions(
|
||||||
|
profiles: readonly MailServerProfile[]
|
||||||
|
): ReferenceOption[] {
|
||||||
|
return profiles.flatMap((profile) =>
|
||||||
|
(profile.servers ?? []).map((server) => ({
|
||||||
|
value: `mail:${server.id}`,
|
||||||
|
label: server.name,
|
||||||
|
description: [
|
||||||
|
profile.name,
|
||||||
|
server.protocol.toUpperCase(),
|
||||||
|
server.is_active ? null : "Inactive",
|
||||||
|
server.id
|
||||||
|
].filter(Boolean).join(" · "),
|
||||||
|
searchText: [
|
||||||
|
profile.slug,
|
||||||
|
profile.name,
|
||||||
|
server.name,
|
||||||
|
server.protocol,
|
||||||
|
server.id
|
||||||
|
].join(" "),
|
||||||
|
kind: "mail_server",
|
||||||
|
availability: server.is_active ? "available" : "inactive",
|
||||||
|
disabled: !server.is_active,
|
||||||
|
sourceModule: "mail",
|
||||||
|
provenance: {
|
||||||
|
profileId: profile.id,
|
||||||
|
serverId: server.id,
|
||||||
|
protocol: server.protocol,
|
||||||
|
scopeType: server.scope_type,
|
||||||
|
scopeId: server.scope_id
|
||||||
|
}
|
||||||
|
} satisfies ReferenceOption))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProfiles(
|
||||||
|
settings: ApiSettings,
|
||||||
|
context: CredentialReferenceSelectorContext,
|
||||||
|
signal: AbortSignal
|
||||||
|
): Promise<MailServerProfile[]> {
|
||||||
|
let watermark: string | null = null;
|
||||||
|
let profiles: MailServerProfile[] = [];
|
||||||
|
let first = true;
|
||||||
|
do {
|
||||||
|
const response = await fetchMailSettingsDelta(settings, {
|
||||||
|
scope_type: context.scopeType,
|
||||||
|
scope_id: context.scopeId,
|
||||||
|
include_inactive: true,
|
||||||
|
since: first ? null : watermark,
|
||||||
|
limit: 200
|
||||||
|
});
|
||||||
|
if (signal.aborted) throw abortError();
|
||||||
|
profiles = response.full
|
||||||
|
? response.profiles
|
||||||
|
: mergeProfiles(profiles, response.profiles, response.deleted);
|
||||||
|
watermark = response.watermark ?? null;
|
||||||
|
first = false;
|
||||||
|
if (!response.has_more) break;
|
||||||
|
} while (watermark);
|
||||||
|
return profiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeProfiles(
|
||||||
|
current: readonly MailServerProfile[],
|
||||||
|
changed: readonly MailServerProfile[],
|
||||||
|
deleted: readonly { resource_type: string; resource_id: string }[]
|
||||||
|
): MailServerProfile[] {
|
||||||
|
const removed = new Set(
|
||||||
|
deleted
|
||||||
|
.filter((item) => item.resource_type === "mail_profile")
|
||||||
|
.map((item) => item.resource_id)
|
||||||
|
);
|
||||||
|
const merged = new Map(
|
||||||
|
current
|
||||||
|
.filter((profile) => !removed.has(profile.id))
|
||||||
|
.map((profile) => [profile.id, profile])
|
||||||
|
);
|
||||||
|
for (const profile of changed) merged.set(profile.id, profile);
|
||||||
|
return [...merged.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function abortError(): DOMException {
|
||||||
|
return new DOMException("The operation was aborted.", "AbortError");
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createElement, lazy } from "react";
|
|||||||
import type { MailDevMailboxUiCapability, MailProfilesUiCapability, PlatformWebModule } from "@govoplan/core-webui";
|
import type { MailDevMailboxUiCapability, MailProfilesUiCapability, PlatformWebModule } from "@govoplan/core-webui";
|
||||||
import { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement";
|
import { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement";
|
||||||
import { validateMailPolicy } from "./features/mail/mailPolicyValidation";
|
import { validateMailPolicy } from "./features/mail/mailPolicyValidation";
|
||||||
|
import { mailCredentialReferenceSelectors } from "./features/mail/mailReferenceProviders";
|
||||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
import "./styles/mail-profiles.css";
|
import "./styles/mail-profiles.css";
|
||||||
|
|
||||||
@@ -19,12 +20,20 @@ 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 }) }],
|
||||||
|
|
||||||
uiCapabilities: {
|
uiCapabilities: {
|
||||||
"mail.profiles": { MailProfileScopeManager, MailProfilePolicyEditor, validateMailPolicy } satisfies MailProfilesUiCapability
|
"mail.profiles": { MailProfileScopeManager, MailProfilePolicyEditor, validateMailPolicy } satisfies MailProfilesUiCapability,
|
||||||
|
"core.credentialReferenceSelectors": mailCredentialReferenceSelectors
|
||||||
},
|
},
|
||||||
runtimeUiCapabilities: {
|
runtimeUiCapabilities: {
|
||||||
"mail.devMailbox": { enabled: true, label: "i18n:govoplan-mail.development_mock_mailbox.1a379865" } satisfies MailDevMailboxUiCapability
|
"mail.devMailbox": { enabled: true, label: "i18n:govoplan-mail.development_mock_mailbox.1a379865" } satisfies MailDevMailboxUiCapability
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import {
|
|||||||
mailProfileEditTargetPanelMode,
|
mailProfileEditTargetPanelMode,
|
||||||
mailProfileEditTargetShowsProfileFields,
|
mailProfileEditTargetShowsProfileFields,
|
||||||
mailProfileEditTargetShowsSettingsPanel,
|
mailProfileEditTargetShowsSettingsPanel,
|
||||||
mailProfileEditTargetVisibleSections
|
mailProfileEditTargetVisibleSections,
|
||||||
|
mailProfileCreateCredentialsPayload,
|
||||||
|
mailProfileTargetedUpdatePayload
|
||||||
} from "../src/features/mail/mailProfileEditorModel";
|
} from "../src/features/mail/mailProfileEditorModel";
|
||||||
|
|
||||||
const smtpOnlyChildren = mailProfileChildDescriptors({ id: "profile-1", imap: null });
|
const smtpOnlyChildren = mailProfileChildDescriptors({ id: "profile-1", imap: null });
|
||||||
@@ -46,3 +48,42 @@ assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "profile" }), true);
|
|||||||
assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "server", protocol: "smtp" }), false);
|
assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "server", protocol: "smtp" }), false);
|
||||||
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "profile" }), false);
|
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "profile" }), false);
|
||||||
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "create" }), true);
|
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "create" }), true);
|
||||||
|
|
||||||
|
const updateParts = {
|
||||||
|
profile: { name: "Renamed" },
|
||||||
|
smtp: { host: "smtp.example.org" },
|
||||||
|
imap: { host: "imap.example.org" },
|
||||||
|
credentials: {
|
||||||
|
smtp: { username: "smtp-user", password: "smtp-secret" },
|
||||||
|
imap: { username: "imap-user", password: "imap-secret" }
|
||||||
|
},
|
||||||
|
clearImap: false
|
||||||
|
};
|
||||||
|
assertDeepEqual(
|
||||||
|
mailProfileTargetedUpdatePayload({ kind: "profile" }, updateParts),
|
||||||
|
{ name: "Renamed" },
|
||||||
|
"profile edits do not replay transport or credential fields"
|
||||||
|
);
|
||||||
|
assertDeepEqual(
|
||||||
|
mailProfileTargetedUpdatePayload({ kind: "server", protocol: "smtp" }, updateParts),
|
||||||
|
{ smtp: { host: "smtp.example.org" } },
|
||||||
|
"server edits do not replay credential fields"
|
||||||
|
);
|
||||||
|
assertDeepEqual(
|
||||||
|
mailProfileTargetedUpdatePayload({ kind: "credentials", protocol: "imap" }, updateParts),
|
||||||
|
{ credentials: { imap: { username: "imap-user", password: "imap-secret" } } },
|
||||||
|
"credential edits send only the selected protocol"
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
mailProfileCreateCredentialsPayload({ username: null }, { password: "" }),
|
||||||
|
undefined,
|
||||||
|
"credential-free creates omit the credential object"
|
||||||
|
);
|
||||||
|
assertDeepEqual(
|
||||||
|
mailProfileCreateCredentialsPayload(
|
||||||
|
{ username: "smtp-user", password: null },
|
||||||
|
{ username: null, password: "imap-secret" }
|
||||||
|
),
|
||||||
|
{ smtp: { username: "smtp-user" }, imap: { password: "imap-secret" } },
|
||||||
|
"create payloads retain only explicitly populated credential fields"
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user