feat: integrate governed address contacts

This commit is contained in:
2026-08-20 11:10:18 +02:00
parent 2ad122056e
commit 169b81d9db
14 changed files with 745 additions and 7 deletions
+53 -1
View File
@@ -347,6 +347,12 @@ manifest = ModuleManifest(
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name="addresses.contact_writer",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name=CAPABILITY_POSTBOX_DELIVERY,
version_min="0.1.0",
@@ -583,6 +589,52 @@ manifest = ModuleManifest(
metadata={"kind": "reference", "help_contexts": ["mail.quick_access.messages"]},
order=37,
),
DocumentationTopic(
id="mail.address-book-integration",
title="Use address-book contacts in Mail",
summary="Autocomplete recipients and add message participants to an explicitly writable address book when Addresses is installed.",
body=(
"Mail resolves the optional addresses.lookup and addresses.contact_writer capabilities through the platform registry. "
"Quick Access compose accepts manual recipients in every installation and adds visible contact suggestions when lookup is available, "
"then opens the user's configured mail application. A selected mailbox message offers add-contact actions only through writer decisions "
"returned for the current principal. Read-only connector books and policy-blocked targets remain disabled with the owning Addresses reason. "
"Mail never imports Addresses models, bypasses its scope checks, or changes its read-only mailbox and external-compose custody model."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("mail_user", "mail_admin", "administrator"),
related_modules=("addresses",),
conditions=(
DocumentationCondition(
required_modules=("mail",),
required_scopes=("mail:profile:use",),
),
),
links=(
DocumentationLink(label="Mail", href="/mail", kind="runtime"),
DocumentationLink(label="Addresses", href="/addresses", kind="runtime"),
),
translations={
"de": {
"title": "Adressbuchkontakte in Mail verwenden",
"summary": "Empfänger vervollständigen und Nachrichtenbeteiligte in einem ausdrücklich beschreibbaren Adressbuch speichern, wenn Addresses installiert ist.",
"body": (
"Mail löst die optionalen Capabilities addresses.lookup und addresses.contact_writer über die Plattformregistrierung auf. "
"Beim Verfassen im Schnellzugriff können Empfänger immer manuell eingegeben werden; bei verfügbarer Suche kommen sichtbare Kontaktvorschläge hinzu, "
"anschließend öffnet sich die konfigurierte Mail-Anwendung. Für eine ausgewählte Postfachnachricht werden Kontaktaktionen ausschließlich anhand der "
"Writer-Entscheidungen für den aktuellen Principal angeboten. Schreibgeschützte Connector-Adressbücher und durch Richtlinien gesperrte Ziele bleiben "
"mit dem von Addresses gelieferten Grund deaktiviert. Mail importiert keine Addresses-Modelle, umgeht keine Bereichsprüfung und ändert weder das "
"nur lesende Postfach noch das Verwahrungsmodell des externen Verfassens."
),
}
},
metadata={
"kind": "workflow",
"route": "/mail",
"help_contexts": ["mail.quick_access.messages", "mail.mailbox"],
},
order=38,
),
DocumentationTopic(
id="mail.search.mailbox-messages",
title="Search authorized mailbox messages",
@@ -598,7 +650,7 @@ manifest = ModuleManifest(
documentation_types=("admin", "user"),
audience=("mail_user", "mail_admin", "administrator"),
related_modules=("search",),
order=38,
order=39,
),
DocumentationTopic(
id="mail.profiles-and-policy",
+86
View File
@@ -11,6 +11,10 @@ from sqlalchemy.orm import Session
from govoplan_mail.backend.schemas import (
MailAddressLookupCandidate,
MailAddressLookupResponse,
MailAddressWriteTarget,
MailAddressWriteTargetResponse,
MailContactCreateRequest,
MailContactCreateResponse,
MailConnectionTestResponse,
MailBounceObservationListResponse,
MailBounceObservationResponse,
@@ -149,6 +153,7 @@ MAIL_CREDENTIAL_RESOURCE = "mail_credential"
MAILBOX_MESSAGES_CURSOR_SCOPE = "mail.mailbox.messages.v1"
DEFAULT_MAILBOX_MESSAGE_LIMIT = 50
CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup"
CAPABILITY_ADDRESSES_CONTACT_WRITER = "addresses.contact_writer"
bounce_provider = SqlMailBounceProcessingProvider()
@@ -416,6 +421,15 @@ def _capability_payload(value: object) -> dict[str, Any]:
"source_ref",
"source_revision",
"provenance",
"address_book_label",
"operation",
"allowed",
"reason",
"message",
"scope_type",
"scope_id",
"read_only",
"required_scopes",
):
if hasattr(value, key):
payload[key] = getattr(value, key)
@@ -1361,6 +1375,78 @@ def lookup_mail_addresses(
)
@router.get("/address-write-targets", response_model=MailAddressWriteTargetResponse)
def list_mail_address_write_targets(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> MailAddressWriteTargetResponse:
_require_scope(principal, "mail:profile:use")
capability = _registry_capability(CAPABILITY_ADDRESSES_CONTACT_WRITER)
if capability is None or not hasattr(capability, "list_write_targets"):
return MailAddressWriteTargetResponse(available=False, targets=[])
targets = getattr(capability, "list_write_targets")(session, principal, operation="create_contact")
return MailAddressWriteTargetResponse(
available=True,
targets=[MailAddressWriteTarget.model_validate(_capability_payload(target)) for target in targets],
)
@router.post(
"/address-contacts",
response_model=MailContactCreateResponse,
status_code=status.HTTP_201_CREATED,
)
def create_mail_address_contact(
payload: MailContactCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> MailContactCreateResponse:
_require_scope(principal, "mail:profile:use")
capability = _registry_capability(CAPABILITY_ADDRESSES_CONTACT_WRITER)
if capability is None or not all(
hasattr(capability, method)
for method in ("can_write_to_address_book", "create_contact")
):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Address-book contact writing is not available.",
)
decision = getattr(capability, "can_write_to_address_book")(
session,
principal,
address_book_id=payload.address_book_id,
operation="create_contact",
)
decision_payload = _capability_payload(decision)
if decision_payload.get("allowed") is not True:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(decision_payload.get("message") or "The selected address book is not writable."),
)
try:
result = getattr(capability, "create_contact")(
session,
principal,
address_book_id=payload.address_book_id,
payload={
"display_name": payload.display_name or payload.email,
"emails": [{"label": "Mail", "email": payload.email, "is_primary": True}],
},
provenance={
"consumer_module": MAIL_MODULE_ID,
"consumer_workflow": "mailbox_add_contact",
},
)
session.commit()
except ValueError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
return MailContactCreateResponse.model_validate(_capability_payload(result))
@router.get("/settings/delta", response_model=MailSettingsDeltaResponse)
def mail_settings_delta(
scope_type: str = Query(default="tenant"),
+37
View File
@@ -344,6 +344,43 @@ class MailAddressLookupResponse(BaseModel):
candidates: list[MailAddressLookupCandidate] = Field(default_factory=list)
class MailAddressWriteTarget(BaseModel):
address_book_id: str
address_book_label: str | None = None
operation: str = "create_contact"
allowed: bool = False
reason: str
message: str
scope_type: str | None = None
scope_id: str | None = None
source_kind: str | None = None
read_only: bool = False
required_scopes: list[str] = Field(default_factory=list)
provenance: dict[str, Any] = Field(default_factory=dict)
class MailAddressWriteTargetResponse(BaseModel):
available: bool = False
targets: list[MailAddressWriteTarget] = Field(default_factory=list)
class MailContactCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
address_book_id: str = Field(min_length=1, max_length=36)
display_name: str | None = Field(default=None, max_length=255)
email: str = Field(min_length=3, max_length=320, pattern=r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
class MailContactCreateResponse(BaseModel):
contact_id: str
address_book_id: str
display_name: str
email: str | None = None
source_kind: str = "local"
provenance: dict[str, Any] = Field(default_factory=dict)
class MailConnectionTestResponse(BaseModel):
ok: bool
protocol: Literal["smtp", "imap"]