feat: integrate governed address contacts
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user