10 Commits

32 changed files with 7210 additions and 314 deletions

View File

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

View File

@@ -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
@@ -142,24 +152,38 @@ 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 configured Help Center exposes **Create a custom Mail profile** only when
the current actor has profile-write authority and the effective user-scope the current actor has broad or self-service profile-write authority and the
policy permits user profiles. It states the active SMTP/IMAP hostname 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, allow-list groups and deny rules, plus the actor's separate credential, test,
use, and approval requirements. The Settings task creates in the current use, and approval requirements. The Settings task creates in the current
account's user scope; `mail:profile:write` itself remains broad profile account's user scope. Grant `mail:profile:write_own` for self-service;
administration authority, not an API-enforced self-only permission. `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
@@ -340,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
@@ -375,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.

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ from govoplan_mail.backend.mail_profiles import (
_assert_campaign_inherits_profile_credentials, _assert_campaign_inherits_profile_credentials,
assert_campaign_mail_policy_allows_json, assert_campaign_mail_policy_allows_json,
assert_mail_policy_allows_send, assert_mail_policy_allows_send,
campaign_mail_owner_context,
campaign_profile_transport_revisions, campaign_profile_transport_revisions,
effective_mail_profile_policy, effective_mail_profile_policy,
ensure_mail_profile_allowed_for_campaign, ensure_mail_profile_allowed_for_campaign,
@@ -19,6 +20,12 @@ from govoplan_mail.backend.mail_profiles import (
mail_profile_id_from_campaign_json, mail_profile_id_from_campaign_json,
smtp_config_from_profile, smtp_config_from_profile,
) )
from govoplan_mail.backend.server_hierarchy import (
MailHierarchyContext,
MailServerHierarchyError,
resolve_mail_transport,
select_mail_transport,
)
from govoplan_mail.backend.runtime import configure_runtime from govoplan_mail.backend.runtime import configure_runtime
from govoplan_mail.backend.sending.imap import ( from govoplan_mail.backend.sending.imap import (
ImapAppendError, ImapAppendError,
@@ -104,6 +111,7 @@ def _authorized_campaign_profile(
tenant_id: str, tenant_id: str,
campaign_id: str, campaign_id: str,
profile_id: str, profile_id: str,
selection: dict[str, str | None] | None = None,
): ):
profile = ensure_mail_profile_allowed_for_campaign( profile = ensure_mail_profile_allowed_for_campaign(
session, session,
@@ -113,10 +121,55 @@ def _authorized_campaign_profile(
require_active=True, require_active=True,
) )
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id) policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id)
_assert_campaign_inherits_profile_credentials(profile, policy) _assert_campaign_inherits_profile_credentials(profile, policy, selection)
return profile return profile
def _campaign_hierarchy_context(
session: Session,
*,
tenant_id: str,
campaign_id: str,
) -> MailHierarchyContext:
campaign = campaign_mail_owner_context(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
)
return MailHierarchyContext(
tenant_id=tenant_id,
user_id=campaign.owner_user_id,
group_ids=(
frozenset({campaign.owner_group_id})
if campaign.owner_group_id
else frozenset()
),
target_scope_type="campaign",
target_scope_id=campaign.id,
)
def _selection_payload(
*,
profile_id: str,
smtp_server_id: str | None = None,
smtp_credential_id: str | None = None,
imap_server_id: str | None = None,
imap_credential_id: str | None = None,
) -> dict[str, str | None]:
return {
"mail_profile_id": profile_id,
"smtp_server_id": smtp_server_id,
"smtp_credential_id": smtp_credential_id,
"imap_server_id": imap_server_id,
"imap_credential_id": imap_credential_id,
}
def _supports_hierarchy(session: object) -> bool:
return callable(getattr(session, "execute", None))
def campaign_profile_delivery_summary( def campaign_profile_delivery_summary(
session: Session, session: Session,
*, *,
@@ -125,21 +178,33 @@ def campaign_profile_delivery_summary(
campaign_id: str | None = None, campaign_id: str | None = None,
owner_user_id: str | None = None, owner_user_id: str | None = None,
owner_group_id: str | None = None, owner_group_id: str | None = None,
smtp_server_id: str | None = None,
smtp_credential_id: str | None = None,
imap_server_id: str | None = None,
imap_credential_id: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return only non-secret capabilities and opaque drift evidence.""" """Return only non-secret capabilities and opaque drift evidence."""
selection = _selection_payload(
profile_id=profile_id,
smtp_server_id=smtp_server_id,
smtp_credential_id=smtp_credential_id,
imap_server_id=imap_server_id,
imap_credential_id=imap_credential_id,
)
if campaign_id: if campaign_id:
profile = _authorized_campaign_profile( profile = _authorized_campaign_profile(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
campaign_id=campaign_id, campaign_id=campaign_id,
profile_id=profile_id, profile_id=profile_id,
selection=selection,
) )
else: else:
assert_campaign_mail_policy_allows_json( assert_campaign_mail_policy_allows_json(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
raw_json={"server": {"mail_profile_id": profile_id}}, raw_json={"server": {key: value for key, value in selection.items() if value}},
owner_user_id=owner_user_id, owner_user_id=owner_user_id,
owner_group_id=owner_group_id, owner_group_id=owner_group_id,
) )
@@ -149,15 +214,61 @@ def campaign_profile_delivery_summary(
profile_id=profile_id, profile_id=profile_id,
require_active=True, require_active=True,
) )
if campaign_id and _supports_hierarchy(session):
context = _campaign_hierarchy_context(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
)
try:
smtp = select_mail_transport(
session,
profile=profile,
protocol="smtp",
context=context,
server_id=smtp_server_id,
credential_id=smtp_credential_id,
)
imap = select_mail_transport(
session,
profile=profile,
protocol="imap",
context=context,
server_id=imap_server_id,
credential_id=imap_credential_id,
)
except MailServerHierarchyError as exc:
raise MailProfileError(str(exc)) from exc
smtp_available = smtp.available
imap_available = imap.available
smtp_revision = smtp.transport_revision
imap_revision = imap.transport_revision if imap.available else None
resolved_smtp_server_id = smtp.server.id if smtp.server else None
resolved_smtp_credential_id = smtp.credential.id if smtp.credential else None
resolved_imap_server_id = imap.server.id if imap.server else None
resolved_imap_credential_id = imap.credential.id if imap.credential else None
else:
revisions = campaign_profile_transport_revisions(profile) revisions = campaign_profile_transport_revisions(profile)
smtp = profile.smtp_config or {} smtp_config = profile.smtp_config or {}
imap = profile.imap_config or {} imap_config = profile.imap_config or {}
smtp_available = bool(smtp_config.get("host") and smtp_config.get("port"))
imap_available = bool(imap_config.get("host") and imap_config.get("port"))
smtp_revision = revisions["smtp"]
imap_revision = revisions["imap"]
resolved_smtp_server_id = smtp_server_id
resolved_smtp_credential_id = smtp_credential_id
resolved_imap_server_id = imap_server_id
resolved_imap_credential_id = imap_credential_id
return { return {
"mail_profile_id": profile_id, "mail_profile_id": profile_id,
"smtp_available": bool(smtp.get("host") and smtp.get("port")), "smtp_server_id": resolved_smtp_server_id,
"imap_available": bool(imap.get("host") and imap.get("port")), "smtp_credential_id": resolved_smtp_credential_id,
"smtp_transport_revision": revisions["smtp"], "imap_server_id": resolved_imap_server_id,
"imap_transport_revision": revisions["imap"], "imap_credential_id": resolved_imap_credential_id,
"smtp_available": smtp_available,
"imap_available": imap_available,
"smtp_transport_revision": smtp_revision,
"imap_transport_revision": imap_revision,
} }
@@ -172,25 +283,64 @@ def send_campaign_email_bytes(
envelope_recipients: list[str], envelope_recipients: list[str],
from_header: str | None, from_header: str | None,
expected_smtp_transport_revision: str, expected_smtp_transport_revision: str,
smtp_server_id: str | None = None,
smtp_credential_id: str | None = None,
) -> CampaignSmtpDeliveryResult: ) -> CampaignSmtpDeliveryResult:
selection = _selection_payload(
profile_id=profile_id,
smtp_server_id=smtp_server_id,
smtp_credential_id=smtp_credential_id,
)
try: try:
profile = _authorized_campaign_profile( profile = _authorized_campaign_profile(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
campaign_id=campaign_id, campaign_id=campaign_id,
profile_id=profile_id, profile_id=profile_id,
selection=selection,
) )
except MailProfileError: except MailProfileError:
raise raise
except Exception: except Exception:
raise SmtpConfigurationError("The selected Mail profile's SMTP configuration is unusable.") from None raise SmtpConfigurationError("The selected Mail profile's SMTP configuration is unusable.") from None
revisions = campaign_profile_transport_revisions(profile) resolved_smtp = None
if revisions["smtp"] != expected_smtp_transport_revision: if _supports_hierarchy(session):
context = _campaign_hierarchy_context(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
)
try:
selected_smtp = select_mail_transport(
session,
profile=profile,
protocol="smtp",
context=context,
server_id=smtp_server_id,
credential_id=smtp_credential_id,
)
except MailServerHierarchyError as exc:
raise MailProfileError(str(exc)) from exc
current_smtp_revision = selected_smtp.transport_revision
else:
current_smtp_revision = campaign_profile_transport_revisions(profile)["smtp"]
if current_smtp_revision != expected_smtp_transport_revision:
raise MailProfileError( raise MailProfileError(
"The selected Mail profile's SMTP settings changed after this campaign was built. " "The selected Mail profile's SMTP settings changed after this campaign was built. "
"Revalidate and rebuild the campaign before delivery." "Revalidate and rebuild the campaign before delivery."
) )
try: try:
if _supports_hierarchy(session):
resolved_smtp = resolve_mail_transport(
session,
profile=profile,
protocol="smtp",
context=context,
server_id=smtp_server_id,
credential_id=smtp_credential_id,
)
smtp = resolved_smtp.config
else:
smtp = smtp_config_from_profile(profile) smtp = smtp_config_from_profile(profile)
except MailProfileError: except MailProfileError:
raise raise
@@ -202,7 +352,7 @@ def send_campaign_email_bytes(
tenant_id=tenant_id, tenant_id=tenant_id,
campaign_id=campaign_id, campaign_id=campaign_id,
smtp=smtp, smtp=smtp,
imap=profile.imap_config or None, imap=None,
envelope_sender=envelope_from, envelope_sender=envelope_from,
from_header=from_header, from_header=from_header,
recipients=envelope_recipients, recipients=envelope_recipients,
@@ -241,30 +391,82 @@ def append_campaign_message_to_sent(
folder: str | None, folder: str | None,
expected_smtp_transport_revision: str, expected_smtp_transport_revision: str,
expected_imap_transport_revision: str | None, expected_imap_transport_revision: str | None,
smtp_server_id: str | None = None,
smtp_credential_id: str | None = None,
imap_server_id: str | None = None,
imap_credential_id: str | None = None,
) -> CampaignImapAppendResult: ) -> CampaignImapAppendResult:
selection = _selection_payload(
profile_id=profile_id,
smtp_server_id=smtp_server_id,
smtp_credential_id=smtp_credential_id,
imap_server_id=imap_server_id,
imap_credential_id=imap_credential_id,
)
try: try:
profile = _authorized_campaign_profile( profile = _authorized_campaign_profile(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
campaign_id=campaign_id, campaign_id=campaign_id,
profile_id=profile_id, profile_id=profile_id,
selection=selection,
) )
except MailProfileError: except MailProfileError:
raise raise
except Exception: except Exception:
raise ImapConfigurationError("The selected Mail profile's IMAP configuration is unusable.") from None raise ImapConfigurationError("The selected Mail profile's IMAP configuration is unusable.") from None
if _supports_hierarchy(session):
context = _campaign_hierarchy_context(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
)
try:
selected_smtp = select_mail_transport(
session,
profile=profile,
protocol="smtp",
context=context,
server_id=smtp_server_id,
credential_id=smtp_credential_id,
)
selected_imap = select_mail_transport(
session,
profile=profile,
protocol="imap",
context=context,
server_id=imap_server_id,
credential_id=imap_credential_id,
)
except MailServerHierarchyError as exc:
raise MailProfileError(str(exc)) from exc
smtp_revision = selected_smtp.transport_revision
imap_revision = selected_imap.transport_revision
else:
revisions = campaign_profile_transport_revisions(profile) revisions = campaign_profile_transport_revisions(profile)
if revisions["smtp"] != expected_smtp_transport_revision: smtp_revision = revisions["smtp"]
imap_revision = revisions["imap"]
if smtp_revision != expected_smtp_transport_revision:
raise MailProfileError( raise MailProfileError(
"The selected Mail profile's SMTP settings changed after this campaign was built. " "The selected Mail profile's SMTP settings changed after this campaign was built. "
"Revalidate and rebuild the campaign before append-to-Sent delivery." "Revalidate and rebuild the campaign before append-to-Sent delivery."
) )
if revisions["imap"] != expected_imap_transport_revision: if imap_revision != expected_imap_transport_revision:
raise MailProfileError( raise MailProfileError(
"The selected Mail profile's IMAP settings changed after this campaign was built. " "The selected Mail profile's IMAP settings changed after this campaign was built. "
"Revalidate and rebuild the campaign before append-to-Sent delivery." "Revalidate and rebuild the campaign before append-to-Sent delivery."
) )
try: try:
if _supports_hierarchy(session):
imap = resolve_mail_transport(
session,
profile=profile,
protocol="imap",
context=context,
server_id=imap_server_id,
credential_id=imap_credential_id,
).config
else:
imap = imap_config_from_profile(profile) imap = imap_config_from_profile(profile)
except MailProfileError: except MailProfileError:
raise raise
@@ -277,7 +479,7 @@ def append_campaign_message_to_sent(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
campaign_id=campaign_id, campaign_id=campaign_id,
smtp=profile.smtp_config or None, smtp=None,
imap=imap, imap=imap,
) )
except MailProfileError: except MailProfileError:
@@ -309,6 +511,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 +547,10 @@ 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()

View File

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

View 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 and_, 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)

View File

@@ -4,7 +4,13 @@ from typing import Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.core.modules import DocumentationCondition, DocumentationContext, DocumentationLink, DocumentationTopic from govoplan_core.core.modules import (
DocumentationCondition,
DocumentationConfigurationDecision,
DocumentationContext,
DocumentationLink,
DocumentationTopic,
)
from govoplan_mail.backend.mail_profiles import ( from govoplan_mail.backend.mail_profiles import (
EffectiveMailProfilePolicy, EffectiveMailProfilePolicy,
MailProfileError, MailProfileError,
@@ -14,7 +20,10 @@ from govoplan_mail.backend.mail_profiles import (
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_READ_SCOPE = "mail:profile:read"
MAIL_PROFILE_WRITE_SCOPE = "mail:profile:write" MAIL_PROFILE_WRITE_SCOPE = "mail:profile:write"
MAIL_SECRET_MANAGE_SCOPE = "mail:secret:manage" 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_TEST_SCOPE = "mail:profile:test"
MAIL_PROFILE_USE_SCOPE = "mail:profile:use" MAIL_PROFILE_USE_SCOPE = "mail:profile:use"
_HOST_POLICY_FIELDS = (("SMTP", "smtp_hosts"), ("IMAP", "imap_hosts")) _HOST_POLICY_FIELDS = (("SMTP", "smtp_hosts"), ("IMAP", "imap_hosts"))
@@ -32,6 +41,60 @@ def documentation_topics(context: DocumentationContext) -> tuple[DocumentationTo
return tuple(topics) 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:
principal = context.principal principal = context.principal
tenant_id = str(getattr(principal, "tenant_id", "") or "") tenant_id = str(getattr(principal, "tenant_id", "") or "")
@@ -120,7 +183,9 @@ def _custom_mail_profile_topic(context: DocumentationContext) -> DocumentationTo
if context.documentation_type != "user": if context.documentation_type != "user":
return None return None
principal = context.principal principal = context.principal
if not _has_all_scopes(principal, (MAIL_PROFILE_READ_SCOPE, MAIL_PROFILE_WRITE_SCOPE)): 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 return None
tenant_id = str(getattr(principal, "tenant_id", "") or "") tenant_id = str(getattr(principal, "tenant_id", "") or "")
user_id = str(getattr(getattr(principal, "user", None), "id", "") or "") user_id = str(getattr(getattr(principal, "user", None), "id", "") or "")
@@ -143,7 +208,10 @@ def _custom_mail_profile_topic(context: DocumentationContext) -> DocumentationTo
if not policy.allow_user_profiles: if not policy.allow_user_profiles:
return None return None
can_manage_credentials = _has_any_scope(principal, (MAIL_SECRET_MANAGE_SCOPE,)) 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_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,)) can_use_profile = _has_any_scope(principal, (MAIL_PROFILE_USE_SCOPE,))
approval_required = bool(policy.allowed_profile_id_sets) approval_required = bool(policy.allowed_profile_id_sets)
@@ -175,7 +243,8 @@ def _custom_mail_profile_topic(context: DocumentationContext) -> DocumentationTo
conditions=( conditions=(
DocumentationCondition( DocumentationCondition(
required_modules=("mail",), required_modules=("mail",),
required_scopes=(MAIL_PROFILE_READ_SCOPE, MAIL_PROFILE_WRITE_SCOPE), required_scopes=(MAIL_PROFILE_READ_SCOPE,),
any_scopes=(MAIL_PROFILE_WRITE_SCOPE, MAIL_PROFILE_WRITE_OWN_SCOPE),
configuration_keys=("mail_profile_policy",), configuration_keys=("mail_profile_policy",),
), ),
), ),

View File

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

View File

@@ -6,12 +6,15 @@ 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
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 +24,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 +92,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 +127,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 +137,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 +173,13 @@ 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"),
), ),
requires_interfaces=( requires_interfaces=(
ModuleInterfaceRequirement( ModuleInterfaceRequirement(
@@ -159,7 +208,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 +235,24 @@ 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),
}, },
documentation=( documentation=(
DocumentationTopic( DocumentationTopic(
@@ -231,7 +304,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=(
@@ -378,7 +456,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"),
@@ -411,6 +489,12 @@ manifest = ModuleManifest(
), ),
), ),
documentation_providers=(documentation_topics,), documentation_providers=(documentation_topics,),
documentation_configuration_providers=(
DocumentationConfigurationProviderRegistration(
keys=("mail_profile_policy",),
resolve=documentation_configuration_states,
),
),
) )

View File

@@ -0,0 +1,22 @@
"""split mail envelopes into servers and reusable credential bindings
Revision ID: 7192a3bcdef0
Revises: 608192abcdef
Create Date: 2026-07-23 00:00:00.000000
"""
from __future__ import annotations
from importlib import import_module
hierarchy = import_module(
"govoplan_mail.backend.migrations.versions.7192a3bcdef0_mail_server_hierarchy"
)
revision = hierarchy.revision
down_revision = hierarchy.down_revision
branch_labels = hierarchy.branch_labels
depends_on = hierarchy.depends_on
upgrade = hierarchy.upgrade
downgrade = hierarchy.downgrade

View File

@@ -0,0 +1,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

View File

@@ -0,0 +1,265 @@
"""split mail envelopes into servers and reusable credential bindings
Revision ID: 7192a3bcdef0
Revises: 608192abcdef
Create Date: 2026-07-23 00:00:00.000000
"""
from __future__ import annotations
import json
import uuid
from datetime import datetime, timezone
from alembic import op
import sqlalchemy as sa
revision = "7192a3bcdef0"
down_revision = "608192abcdef"
branch_labels = None
depends_on = "c91f0a72be34"
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
if "mail_server_profiles" not in tables:
return
profile_columns = {column["name"] for column in inspector.get_columns("mail_server_profiles")}
if "inherit_to_lower_scopes" not in profile_columns:
with op.batch_alter_table("mail_server_profiles") as batch:
batch.add_column(
sa.Column(
"inherit_to_lower_scopes",
sa.Boolean(),
nullable=False,
server_default=sa.true(),
)
)
if "mail_server_endpoints" not in tables:
op.create_table(
"mail_server_endpoints",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("profile_id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("protocol", sa.String(length=20), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("config", sa.JSON(), nullable=False),
sa.Column("scope_type", sa.String(length=20), nullable=False),
sa.Column("scope_id", sa.String(length=36), nullable=True),
sa.Column("inherit_to_lower_scopes", sa.Boolean(), nullable=False),
sa.Column("is_default", sa.Boolean(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("transport_revision", sa.String(length=36), nullable=False),
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
sa.Column("updated_by_user_id", sa.String(length=36), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["profile_id"],
["mail_server_profiles.id"],
name=op.f("fk_mail_server_endpoints_profile_id_mail_server_profiles"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["tenant_id"],
["core_scopes.id"],
name=op.f("fk_mail_server_endpoints_tenant_id_core_scopes"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["created_by_user_id"],
["access_users.id"],
name=op.f("fk_mail_server_endpoints_created_by_user_id_access_users"),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["updated_by_user_id"],
["access_users.id"],
name=op.f("fk_mail_server_endpoints_updated_by_user_id_access_users"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_mail_server_endpoints")),
sa.UniqueConstraint(
"profile_id",
"protocol",
"name",
name="uq_mail_server_endpoints_profile_protocol_name",
),
)
_create_endpoint_indexes()
inspector = sa.inspect(bind)
if "mail_server_credential_bindings" not in inspector.get_table_names():
op.create_table(
"mail_server_credential_bindings",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("server_id", sa.String(length=36), nullable=False),
sa.Column("credential_id", sa.String(length=36), nullable=False),
sa.Column("is_default", sa.Boolean(), nullable=False),
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["server_id"],
["mail_server_endpoints.id"],
name=op.f("fk_mail_server_credential_bindings_server_id_mail_server_endpoints"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["credential_id"],
["core_credential_envelopes.id"],
name=op.f("fk_mail_server_credential_bindings_credential_id_core_credential_envelopes"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["created_by_user_id"],
["access_users.id"],
name=op.f("fk_mail_server_credential_bindings_created_by_user_id_access_users"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_mail_server_credential_bindings")),
sa.UniqueConstraint(
"server_id",
"credential_id",
name="uq_mail_server_credential_bindings_server_credential",
),
)
op.create_index(
"ix_mail_server_credential_bindings_default",
"mail_server_credential_bindings",
["server_id", "is_default"],
unique=False,
)
for column in ("server_id", "credential_id", "is_default", "created_by_user_id"):
op.create_index(
op.f(f"ix_mail_server_credential_bindings_{column}"),
"mail_server_credential_bindings",
[column],
unique=False,
)
_seed_legacy_endpoints(bind)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "mail_server_credential_bindings" in tables:
op.drop_table("mail_server_credential_bindings")
if "mail_server_endpoints" in tables:
op.drop_table("mail_server_endpoints")
if "mail_server_profiles" in tables:
columns = {column["name"] for column in inspector.get_columns("mail_server_profiles")}
if "inherit_to_lower_scopes" in columns:
with op.batch_alter_table("mail_server_profiles") as batch:
batch.drop_column("inherit_to_lower_scopes")
def _create_endpoint_indexes() -> None:
op.create_index(
"ix_mail_server_endpoints_profile_protocol",
"mail_server_endpoints",
["profile_id", "protocol", "is_active"],
unique=False,
)
op.create_index(
"ix_mail_server_endpoints_scope",
"mail_server_endpoints",
["tenant_id", "scope_type", "scope_id"],
unique=False,
)
for column in (
"profile_id",
"tenant_id",
"protocol",
"scope_type",
"scope_id",
"is_default",
"is_active",
"created_by_user_id",
"updated_by_user_id",
):
op.create_index(
op.f(f"ix_mail_server_endpoints_{column}"),
"mail_server_endpoints",
[column],
unique=False,
)
def _seed_legacy_endpoints(bind) -> None:
existing = {
(row.profile_id, row.protocol)
for row in bind.execute(
sa.text("SELECT profile_id, protocol FROM mail_server_endpoints WHERE is_default = :is_default"),
{"is_default": True},
)
}
rows = bind.execute(
sa.text(
"SELECT id, tenant_id, scope_type, scope_id, smtp_config, imap_config, "
"smtp_transport_revision, imap_transport_revision, created_by_user_id, updated_by_user_id "
"FROM mail_server_profiles"
)
).mappings()
now = datetime.now(timezone.utc)
table = sa.table(
"mail_server_endpoints",
sa.column("id", sa.String),
sa.column("profile_id", sa.String),
sa.column("tenant_id", sa.String),
sa.column("protocol", sa.String),
sa.column("name", sa.String),
sa.column("config", sa.JSON),
sa.column("scope_type", sa.String),
sa.column("scope_id", sa.String),
sa.column("inherit_to_lower_scopes", sa.Boolean),
sa.column("is_default", sa.Boolean),
sa.column("is_active", sa.Boolean),
sa.column("transport_revision", sa.String),
sa.column("created_by_user_id", sa.String),
sa.column("updated_by_user_id", sa.String),
sa.column("created_at", sa.DateTime(timezone=True)),
sa.column("updated_at", sa.DateTime(timezone=True)),
)
for row in rows:
for protocol, config_key, revision_key in (
("smtp", "smtp_config", "smtp_transport_revision"),
("imap", "imap_config", "imap_transport_revision"),
):
config = _json_object(row[config_key])
if not config or (row["id"], protocol) in existing:
continue
bind.execute(
table.insert().values(
id=str(uuid.uuid4()),
profile_id=row["id"],
tenant_id=row["tenant_id"],
protocol=protocol,
name=protocol.upper(),
config=config,
scope_type=row["scope_type"] or "tenant",
scope_id=row["scope_id"],
inherit_to_lower_scopes=True,
is_default=True,
is_active=True,
transport_revision=row[revision_key] or str(uuid.uuid4()),
created_by_user_id=row["created_by_user_id"],
updated_by_user_id=row["updated_by_user_id"],
created_at=now,
updated_at=now,
)
)
def _json_object(value):
if isinstance(value, dict):
return value
if isinstance(value, str) and value.strip():
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {}
return {}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View 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()

View File

@@ -7,7 +7,10 @@ from unittest.mock import patch
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.core.modules import DocumentationContext from govoplan_core.core.modules import DocumentationContext
from govoplan_mail.backend.documentation import documentation_topics from govoplan_mail.backend.documentation import (
documentation_configuration_states,
documentation_topics,
)
from govoplan_mail.backend.mail_profiles import EffectiveMailProfilePolicy, MailProfileError from govoplan_mail.backend.mail_profiles import EffectiveMailProfilePolicy, MailProfileError
@@ -70,6 +73,20 @@ class MailRuntimeDocumentationTests(unittest.TestCase):
self.assertEqual(task.metadata["help_contexts"], ["mail.profiles", "app.settings"]) self.assertEqual(task.metadata["help_contexts"], ["mail.profiles", "app.settings"])
self.assertIn("current account's user scope", task.metadata["prerequisites"][0]) 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: def test_custom_profile_task_distinguishes_secret_test_and_use_authority(self) -> None:
policy = EffectiveMailProfilePolicy() policy = EffectiveMailProfilePolicy()
cases = ( cases = (
@@ -79,6 +96,7 @@ class MailRuntimeDocumentationTests(unittest.TestCase):
({"mail:profile:use"}, False, False, True), ({"mail:profile:use"}, False, False, True),
({"mail:profile:test", "mail:profile:use"}, False, True, 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", "mail:profile:test", "mail:profile:use"}, True, True, True),
({"mail:secret:manage_own"}, True, False, False),
) )
for extra_scopes, credentials, testing, use in cases: for extra_scopes, credentials, testing, use in cases:
with self.subTest(extra_scopes=extra_scopes): with self.subTest(extra_scopes=extra_scopes):
@@ -166,6 +184,48 @@ class MailRuntimeDocumentationTests(unittest.TestCase):
self.assertEqual(topics["mail.workflow.choose-and-test-profile"].metadata["help_contexts"], ["mail.profiles", "app.settings"]) 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"]) 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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");
}

View File

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

View File

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

View File

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