feat: add governed Mail DSAR coverage

This commit is contained in:
2026-08-20 23:58:21 +02:00
parent a044fee379
commit 34bd5be8d4
4 changed files with 997 additions and 1 deletions
+554
View File
@@ -0,0 +1,554 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from datetime import datetime, timezone
from email.utils import getaddresses
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_mail.backend.db.models import (
MailBounceObservation,
MailDeliveryAttempt,
MailDeliveryCommand,
MailDeliveryReconciliation,
MailMailboxMessageIndex,
MailServerProfile,
)
MAIL_DSAR_CAPABILITY = dsar_capability_name("mail")
_MAX_RECORDS = 5_000
class MailDsarProvider:
provider_id = "mail"
module_id = "mail"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
email = _subject_email(subject)
membership_ids = _membership_ids(subject)
references = _mail_references(subject)
if email is None and not membership_ids and not references:
return ()
records: list[DsarRecordRef] = []
def append(record: DsarRecordRef) -> None:
if len(records) >= _MAX_RECORDS:
raise ValueError(
"Mail DSAR match limit exceeded; narrow the subject selectors."
)
records.append(record)
profiles = _matching_profiles(
db,
tenant_id=tenant_id,
membership_ids=membership_ids,
profile_id=references.get("profile"),
)
for profile in profiles:
append(
_record(
"mail_server_profile",
profile.id,
"mail_profile",
profile.name,
{
"match_fields": _profile_matching_fields(
profile, membership_ids
),
"name": profile.name,
"slug": profile.slug,
"description": profile.description,
"scope_type": profile.scope_type,
"is_active": profile.is_active,
"inherit_to_lower_scopes": profile.inherit_to_lower_scopes,
},
observed_at=profile.updated_at,
source_path="/settings?section=mail-profiles",
)
)
messages = _matching_messages(
db,
tenant_id=tenant_id,
email=email,
message_id=references.get("message_index"),
)
for message in messages:
matching_headers = _matching_headers(message, email)
append(
_record(
"mailbox_message_index",
message.id,
"mailbox_message",
message.subject or "Mailbox message",
{
"match_fields": list(matching_headers),
"subject": _bounded_text(message.subject),
"matching_headers": matching_headers,
"date": message.date,
"flags": tuple(
str(flag)[:100] for flag in (message.flags or ())[:32]
),
"size_bytes": message.size_bytes,
"body_preview": _bounded_text(message.body_preview),
"attachment_count": message.attachment_count,
"indexed_at": _iso(message.indexed_at),
},
observed_at=message.updated_at,
source_path="/mail",
)
)
bounces = _matching_bounces(
db,
tenant_id=tenant_id,
email=email,
bounce_id=references.get("bounce"),
)
bounce_command_ids = {row.command_id for row in bounces if row.command_id}
commands = _matching_commands(
db,
tenant_id=tenant_id,
membership_ids=membership_ids,
command_id=references.get("command"),
related_command_ids=bounce_command_ids,
)
command_ids = {row.id for row in commands}
for command in commands:
append(
_record(
"mail_delivery_command",
command.id,
"mail_delivery_evidence",
f"Mail {command.command_type} command",
{
"match_fields": (
["created_by_user_id"]
if command.created_by_user_id in membership_ids
else (
["reference"]
if command.id == references.get("command")
else ["bounce"]
)
),
"command_type": command.command_type,
"source_module": command.source_module,
"source_resource_type": command.source_resource_type,
"message_sha256": command.message_sha256,
"rfc_message_id": command.rfc_message_id,
"message_size_bytes": command.message_size_bytes,
"recipient_count": command.recipient_count,
"status": command.status,
"attempt_count": command.attempt_count,
"effect_started_at": _iso(command.effect_started_at),
"completed_at": _iso(command.completed_at),
"accepted_count": command.accepted_count,
"refused_count": command.refused_count,
"failure_code": command.failure_code,
"payload_purged_at": _iso(command.payload_purged_at),
},
observed_at=command.updated_at,
immutable=True,
retention_reason="Mail delivery commands are immutable transport, retry, and outcome evidence; encrypted payload retention is governed separately.",
)
)
for attempt in _command_attempts(db, command_ids):
append(
_record(
"mail_delivery_attempt",
attempt.id,
"mail_delivery_evidence",
f"Mail delivery attempt {attempt.attempt_number}",
{
"command_id": attempt.command_id,
"attempt_number": attempt.attempt_number,
"status": attempt.status,
"started_at": _iso(attempt.started_at),
"effect_started_at": _iso(attempt.effect_started_at),
"completed_at": _iso(attempt.completed_at),
"accepted_count": attempt.accepted_count,
"refused_count": attempt.refused_count,
"outcome_code": attempt.outcome_code,
},
observed_at=attempt.updated_at,
immutable=True,
retention_reason="Per-attempt Mail outcome state is immutable delivery and recovery evidence.",
)
)
for reconciliation in _command_reconciliations(db, command_ids):
append(
_record(
"mail_delivery_reconciliation",
reconciliation.id,
"mail_delivery_evidence",
"Mail delivery reconciliation",
{
"command_id": reconciliation.command_id,
"decision": reconciliation.decision,
},
observed_at=reconciliation.updated_at,
immutable=True,
retention_reason="Mail reconciliation decisions are immutable authorization and recovery evidence.",
)
)
for bounce in bounces:
append(
_record(
"mail_bounce_observation",
bounce.id,
"mail_bounce_evidence",
"Mail bounce observation",
{
"match_fields": (
["recipient"]
if email and _normalized_email(bounce.recipient) == email
else ["reference"]
),
"command_id": bounce.command_id
if bounce.command_id in command_ids
else None,
"recipient": email
if email and _normalized_email(bounce.recipient) == email
else None,
"action": bounce.action,
"status_code": bounce.status_code,
"permanent": bounce.permanent,
"observed_at": _iso(bounce.observed_at),
"matched": bounce.matched,
},
observed_at=bounce.updated_at,
immutable=True,
retention_reason="Bounce observations are immutable delivery-status and suppression evidence.",
source_path="/mail/bounces",
)
)
return tuple(records)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
del session, tenant_id, subject
actions = []
for record in records:
if (
record.provider_id != self.provider_id
or record.module_id != self.module_id
):
raise ValueError("Mail DSAR received a foreign provider record.")
actions.append(
DsarErasureActionRef(
action_id=f"mail:{'retain' if record.immutable_evidence else 'review'}:{record.resource_type}:{record.resource_id}",
provider_id="mail",
module_id="mail",
kind="retain" if record.immutable_evidence else "manual_review",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"{'Retain' if record.immutable_evidence else 'Review'} {record.title}",
rationale=record.retention_reason
or "Mailbox indexes and personal profiles must be reviewed through Mail and the authoritative external mailbox lifecycle.",
executable=False,
)
)
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
del session, tenant_id, subject, request_id
return tuple(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary="Mail erasure requires an authorized Mail/external-mailbox lifecycle action; the DSAR provider does not mutate it directly.",
)
for action in actions
)
def _matching_profiles(
session: Session,
*,
tenant_id: str,
membership_ids: set[str],
profile_id: str | None,
) -> list[MailServerProfile]:
conditions = []
if profile_id:
conditions.append(MailServerProfile.id == profile_id)
if membership_ids:
conditions.extend(
(
MailServerProfile.created_by_user_id.in_(membership_ids),
MailServerProfile.updated_by_user_id.in_(membership_ids),
(MailServerProfile.scope_type == "user")
& MailServerProfile.scope_id.in_(membership_ids),
)
)
if not conditions:
return []
return _bounded_rows(
session.query(MailServerProfile)
.filter(MailServerProfile.tenant_id == tenant_id, or_(*conditions))
.order_by(MailServerProfile.id)
)
def _profile_matching_fields(
row: MailServerProfile, membership_ids: set[str]
) -> list[str]:
fields = []
if row.scope_type == "user" and row.scope_id in membership_ids:
fields.append("scope_id")
for field in ("created_by_user_id", "updated_by_user_id"):
if getattr(row, field) in membership_ids:
fields.append(field)
return fields
def _matching_messages(
session: Session, *, tenant_id: str, email: str | None, message_id: str | None
) -> list[MailMailboxMessageIndex]:
conditions = []
if message_id:
conditions.append(MailMailboxMessageIndex.id == message_id)
if email:
pattern = f"%{_escape_like(email)}%"
conditions.extend(
func.lower(field).like(pattern, escape="\\")
for field in (
MailMailboxMessageIndex.from_header,
MailMailboxMessageIndex.to_header,
MailMailboxMessageIndex.cc_header,
)
)
if not conditions:
return []
candidates = _bounded_rows(
session.query(MailMailboxMessageIndex)
.filter(MailMailboxMessageIndex.tenant_id == tenant_id, or_(*conditions))
.order_by(MailMailboxMessageIndex.id)
)
return [
row
for row in candidates
if row.id == message_id or _matching_headers(row, email)
]
def _matching_headers(
row: MailMailboxMessageIndex, email: str | None
) -> dict[str, list[dict[str, str | None]]]:
if email is None:
return {}
result = {}
for role, value in (
("from", row.from_header),
("to", row.to_header),
("cc", row.cc_header),
):
matches = [
{"email": address.casefold(), "name": name or None}
for name, address in getaddresses([value or ""])
if address.casefold() == email
]
if matches:
result[role] = matches[:64]
return result
def _matching_bounces(
session: Session, *, tenant_id: str, email: str | None, bounce_id: str | None
) -> list[MailBounceObservation]:
conditions = []
if bounce_id:
conditions.append(MailBounceObservation.id == bounce_id)
if email:
conditions.append(func.lower(MailBounceObservation.recipient) == email)
if not conditions:
return []
return _bounded_rows(
session.query(MailBounceObservation)
.filter(MailBounceObservation.tenant_id == tenant_id, or_(*conditions))
.order_by(MailBounceObservation.id)
)
def _matching_commands(
session: Session,
*,
tenant_id: str,
membership_ids: set[str],
command_id: str | None,
related_command_ids: set[str],
) -> list[MailDeliveryCommand]:
conditions = []
if command_id:
conditions.append(MailDeliveryCommand.id == command_id)
if related_command_ids:
conditions.append(MailDeliveryCommand.id.in_(related_command_ids))
if membership_ids:
conditions.append(MailDeliveryCommand.created_by_user_id.in_(membership_ids))
if not conditions:
return []
return _bounded_rows(
session.query(MailDeliveryCommand)
.filter(MailDeliveryCommand.tenant_id == tenant_id, or_(*conditions))
.order_by(MailDeliveryCommand.id)
)
def _command_attempts(
session: Session, command_ids: set[str]
) -> list[MailDeliveryAttempt]:
if not command_ids:
return []
return _bounded_rows(
session.query(MailDeliveryAttempt)
.filter(MailDeliveryAttempt.command_id.in_(command_ids))
.order_by(MailDeliveryAttempt.id)
)
def _command_reconciliations(
session: Session, command_ids: set[str]
) -> list[MailDeliveryReconciliation]:
if not command_ids:
return []
return _bounded_rows(
session.query(MailDeliveryReconciliation)
.filter(MailDeliveryReconciliation.command_id.in_(command_ids))
.order_by(MailDeliveryReconciliation.id)
)
def _mail_references(subject: DsarSubjectRef) -> dict[str, str]:
aliases = {
"mail.profile": "profile",
"mail.message_index": "message_index",
"mail.delivery_command": "command",
"mail.bounce_observation": "bounce",
}
return {
target: value
for key, target in aliases.items()
if (value := str(subject.external_references.get(key) or "").strip())
}
def _membership_ids(subject: DsarSubjectRef) -> set[str]:
values = [subject.membership_id]
values.extend(
subject.external_references.get(key)
for key in (
"mail.user",
"mail.membership",
"access.membership",
"membership_id",
)
)
return {value for item in values if (value := str(item or "").strip())}
def _subject_email(subject: DsarSubjectRef) -> str | None:
values = [subject.email, subject.external_references.get("mail.email")]
normalized = {email for value in values if (email := _normalized_email(value))}
return normalized.pop() if len(normalized) == 1 else None
def _normalized_email(value: object) -> str | None:
if not isinstance(value, str):
return None
value = value.strip().casefold()
return value or None
def _record(
resource_type: str,
resource_id: str,
category: str,
title: str,
data: Mapping[str, object],
*,
observed_at: datetime | None = None,
immutable: bool = False,
retention_reason: str | None = None,
source_path: str | None = None,
) -> DsarRecordRef:
return DsarRecordRef(
provider_id="mail",
module_id="mail",
resource_type=resource_type,
resource_id=resource_id,
category=category,
title=title,
data=data,
observed_at=observed_at,
immutable_evidence=immutable,
retention_reason=retention_reason,
source_path=source_path,
)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Mail DSAR provider requires a SQLAlchemy session.")
return value
def _bounded_rows(query: object) -> list[object]:
rows = query.limit(_MAX_RECORDS + 1).all() # type: ignore[attr-defined]
if len(rows) > _MAX_RECORDS:
raise ValueError(
"Mail DSAR match limit exceeded; narrow the subject selectors."
)
return rows
def _bounded_text(value: str | None) -> str | None:
return value[:2_000] if value else None
def _escape_like(value: str) -> str:
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _iso(value: datetime | None) -> str | None:
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.isoformat()
__all__ = ["MAIL_DSAR_CAPABILITY", "MailDsarProvider"]
+60 -1
View File
@@ -15,6 +15,7 @@ from govoplan_core.core.mail import (
from govoplan_core.core.postbox import CAPABILITY_POSTBOX_DELIVERY
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationConfigurationProviderRegistration,
DocumentationLink,
@@ -54,6 +55,7 @@ from govoplan_mail.backend.provider_state import (
smtp_provider_states,
)
from govoplan_mail.backend.db import models as mail_models # noqa: F401 - populate Mail ORM metadata
from govoplan_mail.backend.dsar_provider import MAIL_DSAR_CAPABILITY
from govoplan_mail.backend.search_source import create_mail_search_source
@@ -105,6 +107,13 @@ def _configuration_provider(context: ModuleContext) -> object:
return SqlMailConfigurationProvider()
def _mail_dsar_provider(context: ModuleContext) -> object:
del context
from govoplan_mail.backend.dsar_provider import MailDsarProvider
return MailDsarProvider()
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
@@ -311,7 +320,7 @@ IMAP_PROVIDER = ExternalProviderDeclaration(
manifest = ModuleManifest(
id="mail",
name="Mail",
version="0.1.18",
version="0.1.19",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("campaigns", "addresses", "calendar", "postbox", "search"),
provides_interfaces=(
@@ -321,6 +330,7 @@ manifest = ModuleManifest(
ModuleInterfaceProvider(name="mail.notification_delivery", version="0.1.0"),
ModuleInterfaceProvider(name="mail.bounce_processing", version="0.1.0"),
ModuleInterfaceProvider(name=CAPABILITY_MAIL_POSTBOX_BRIDGE, version="1.0.0"),
ModuleInterfaceProvider(name=MAIL_DSAR_CAPABILITY, version="0.1.0"),
),
requires_interfaces=(
ModuleInterfaceRequirement(
@@ -473,8 +483,57 @@ manifest = ModuleManifest(
"govoplan_mail.backend.postbox_bridge",
fromlist=["create_postbox_bridge"],
).create_postbox_bridge(context),
MAIL_DSAR_CAPABILITY: _mail_dsar_provider,
},
capability_documentation={
MAIL_DSAR_CAPABILITY: CapabilityDocumentation(
label="Mail data-subject request provider",
summary="Finds isolated mailbox-index, personal-profile, delivery, reconciliation, and bounce metadata without exposing transport secrets.",
contract_version="0.1.0",
documentation_types=("admin",),
audience=("privacy_officer", "mail_admin", "records_manager"),
),
},
documentation=(
DocumentationTopic(
id="mail.privacy.data-subject-requests",
title="Review Mail data in a data-subject request",
summary="Collect tenant-scoped Mail metadata while preserving transport evidence and external-mailbox authority.",
body=(
"Mail's DSAR provider searches the effective tenant by normalized header or bounce-recipient email, direct membership references, and namespaced Mail profile, message-index, delivery-command, or bounce references. It isolates only matching From, To, and Cc parties and returns bounded message-index content, safe personal-profile metadata, and delivery, attempt, reconciliation, and bounce outcomes. It excludes SMTP/IMAP configuration, usernames and credentials, encrypted messages and envelopes, refusal detail, mailbox folders and UIDs, endpoint and credential identifiers, idempotency and worker claims, diagnostics, error text, and opaque evidence. "
"Delivery attempts, reconciliation decisions, and bounce observations remain retained as immutable transport and recovery evidence. Mailbox indexes and personal profiles require manual review through Mail and the authoritative external mailbox. The provider performs no direct erasure because deleting a derived index alone would not delete its external source, while changing a profile or delivery command can affect credentials, other users, and preserved evidence."
),
layer="configured",
documentation_types=("admin",),
audience=("privacy_officer", "mail_admin", "records_manager", "operator"),
order=35,
conditions=(
DocumentationCondition(
required_modules=("mail", "access"),
any_scopes=("access:privacy:read", "access:privacy:manage", "access:privacy:erase"),
),
),
links=(
DocumentationLink(label="Data-subject requests", href="/admin?section=tenant-data-subject-requests", kind="runtime"),
DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"),
),
related_modules=("access", "audit", "campaigns", "postbox"),
metadata={
"kind": "workflow",
"route": "/admin?section=tenant-data-subject-requests",
"help_contexts": ["admin.privacy.data-subject-requests"],
"steps": [
"Run the Mail provider search and review mailbox-index, profile, delivery, reconciliation, and bounce dispositions.",
"Retain immutable transport evidence with its reason.",
"Coordinate approved mailbox content deletion with the authoritative external mailbox and then refresh the derived index.",
"Use Mail profile lifecycle controls for approved personal-profile changes; do not edit encrypted payload or evidence rows directly.",
],
"limitations": [
"Encrypted outbound payloads cannot be searched by recipient without an independently corroborated Mail command reference.",
"External mailbox deletion is outside the DSAR provider and must be coordinated with the configured provider.",
],
},
),
DocumentationTopic(
id="mail.postbox.bridge",
title="Bridge selected Mail observations into Postbox",