feat(postbox): reconcile lifecycle notifications
This commit is contained in:
@@ -583,6 +583,54 @@ manifest = ModuleManifest(
|
||||
},
|
||||
order=34,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="postbox.lifecycle-notifications",
|
||||
title="Receive governed Postbox lifecycle notifications",
|
||||
summary="Notify current function holders about durable Postbox work and responsibility changes without copying message content.",
|
||||
body=(
|
||||
"Postbox emits versioned delivery, action-required, escalation-due, read, acknowledgement, assignment, delegation, vacancy, and reassignment events. "
|
||||
"A periodic worker resolves current IDM incumbencies in batches and compares them with a durable metadata-only cursor. The first run establishes a quiet baseline; later changes and failed notification handoffs are deduplicated and retryable. "
|
||||
"When Notifications is installed, newly eligible current holders receive generic in-app entries linked to the owning Postbox or message. Subjects, bodies, participant addresses, attachment details, ciphertext, and key material are never copied into lifecycle event payloads. "
|
||||
"Notifications owns personal preferences, quiet periods, and future email or push channel policy. Postbox remains authoritative and fully usable without that optional module. Opening any notification rechecks current assignment, delegation, classification, and generic Postbox permission, so expired access cannot be restored by an old link."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("administrator", "user", "auditor"),
|
||||
related_modules=("notifications", "idm", "access", "audit"),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Postbox",
|
||||
href="/postbox",
|
||||
kind="runtime",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Geregelte Postfach-Lebenszyklusmeldungen erhalten",
|
||||
"summary": "Aktuelle Funktionsinhaber über dauerhafte Postfacharbeit und Verantwortungswechsel informieren, ohne Nachrichteninhalte zu kopieren.",
|
||||
"body": (
|
||||
"Postbox erzeugt versionierte Ereignisse für Zustellung, Handlungsbedarf, fällige Eskalation, Lesen, Bestätigung, Zuweisung, Delegation, Vakanz und Neubesetzung. "
|
||||
"Ein periodischer Worker löst aktuelle IDM-Stellenbesetzungen gebündelt auf und vergleicht sie mit einem dauerhaften, ausschließlich Metadaten enthaltenden Cursor. Der erste Lauf legt geräuschlos den Ausgangsstand fest; spätere Änderungen und fehlgeschlagene Übergaben werden dedupliziert und erneut versucht. "
|
||||
"Ist Notifications installiert, erhalten neu berechtigte aktuelle Stelleninhaber allgemeine In-App-Hinweise mit Verweis auf das zuständige Postfach oder die Nachricht. Betreff, Inhalt, Beteiligtenadressen, Anlagendetails, Chiffrat und Schlüsselmaterial werden niemals in Lebenszyklusereignisse kopiert. "
|
||||
"Notifications verantwortet persönliche Einstellungen, Ruhezeiten und künftige E-Mail- oder Push-Kanalregeln. Postbox bleibt ohne dieses optionale Modul vollständig funktionsfähig. Beim Öffnen werden aktuelle Zuweisung, Delegation, Klassifikation und allgemeine Postfachberechtigung erneut geprüft; ein alter Link stellt abgelaufenen Zugriff nicht wieder her."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "guide",
|
||||
"help_contexts": [
|
||||
"postbox.inbox.messages",
|
||||
"postbox.blocker.assignment",
|
||||
"postbox.state.unavailable",
|
||||
],
|
||||
"privacy_notes": [
|
||||
"Lifecycle notifications never contain message or attachment content.",
|
||||
"Current access is rechecked when a notification link is opened.",
|
||||
"Notifications owns preference and external-channel enforcement.",
|
||||
],
|
||||
},
|
||||
order=35,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="postbox.content-protection-policy",
|
||||
title="Choose and change Postbox content protection",
|
||||
@@ -645,7 +693,7 @@ manifest = ModuleManifest(
|
||||
"Profile transitions preserve authority evidence and content-digest continuity.",
|
||||
],
|
||||
},
|
||||
order=35,
|
||||
order=36,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="postbox.function-bound-containers",
|
||||
@@ -708,7 +756,7 @@ manifest = ModuleManifest(
|
||||
"Subjects, participants, routing facts, and attachment references remain observable metadata.",
|
||||
],
|
||||
},
|
||||
order=36,
|
||||
order=37,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="postbox.reference.fields-and-consequences",
|
||||
@@ -781,7 +829,7 @@ manifest = ModuleManifest(
|
||||
"withdraw_or_expire": "Blocks future content access while retaining permitted audit metadata.",
|
||||
},
|
||||
},
|
||||
order=37,
|
||||
order=38,
|
||||
),
|
||||
),
|
||||
architecture=declared_module_architecture(
|
||||
|
||||
@@ -770,6 +770,7 @@ def api_deliver_to_postbox(
|
||||
body_text=payload.body_text,
|
||||
sender_label=payload.sender_label,
|
||||
classification=payload.classification,
|
||||
action_required=payload.action_required,
|
||||
participants=tuple(
|
||||
PostboxParticipantRef(**participant.model_dump())
|
||||
for participant in payload.participants
|
||||
|
||||
@@ -230,6 +230,7 @@ class PostboxDeliveryCreateRequest(BaseModel):
|
||||
body_text: str | None = None
|
||||
sender_label: str | None = Field(default=None, max_length=500)
|
||||
classification: PostboxClassification = "internal"
|
||||
action_required: bool = False
|
||||
participants: list[PostboxParticipantPayload] = Field(default_factory=list)
|
||||
attachments: list[PostboxAttachmentPayload] = Field(default_factory=list)
|
||||
expires_at: datetime | None = None
|
||||
|
||||
@@ -152,6 +152,28 @@ def _mapping(value: Mapping[str, object] | None) -> dict[str, object]:
|
||||
return dict(value or {})
|
||||
|
||||
|
||||
def _assignment_notification_snapshot(
|
||||
assignment: OrganizationFunctionAssignmentRef,
|
||||
) -> dict[str, object]:
|
||||
"""Persist only stable, non-content assignment provenance for reconciliation."""
|
||||
|
||||
return {
|
||||
"assignment_id": assignment.id,
|
||||
"source": assignment.source,
|
||||
"delegated_from_assignment_id": assignment.delegated_from_assignment_id,
|
||||
}
|
||||
|
||||
|
||||
def _assignment_notification_recipient(
|
||||
assignment: OrganizationFunctionAssignmentRef,
|
||||
) -> tuple[str, str] | None:
|
||||
if assignment.account_id:
|
||||
return ("account", assignment.account_id)
|
||||
if assignment.identity_id:
|
||||
return ("identity", assignment.identity_id)
|
||||
return None
|
||||
|
||||
|
||||
def _plaintext_digest(value: str | None) -> str:
|
||||
return "sha256:" + hashlib.sha256((value or "").encode("utf-8")).hexdigest()
|
||||
|
||||
@@ -1561,6 +1583,7 @@ class PostboxService:
|
||||
)
|
||||
message_metadata = _mapping(request.metadata)
|
||||
message_metadata["content_digest"] = body_storage["content_digest"]
|
||||
message_metadata["action_required"] = request.action_required
|
||||
message = PostboxMessage(
|
||||
id=message_id,
|
||||
tenant_id=request.tenant_id,
|
||||
@@ -1700,6 +1723,23 @@ class PostboxService:
|
||||
"producer_resource_id": request.producer_resource_id,
|
||||
},
|
||||
)
|
||||
if request.action_required:
|
||||
_publish_postbox_event(
|
||||
db,
|
||||
"postbox.message.action_required.v1",
|
||||
tenant_id=request.tenant_id,
|
||||
resource_type="postbox_message",
|
||||
resource_id=message.id,
|
||||
postbox_id=postbox.id,
|
||||
actor_type="module",
|
||||
actor_id=request.producer_module,
|
||||
payload={
|
||||
"delivery_id": delivery.id,
|
||||
"classification": message.classification,
|
||||
"producer_resource_type": request.producer_resource_type,
|
||||
"producer_resource_id": request.producer_resource_id,
|
||||
},
|
||||
)
|
||||
self._notify_delivery_holders(
|
||||
db,
|
||||
request=request,
|
||||
@@ -1847,6 +1887,370 @@ class PostboxService:
|
||||
db.flush()
|
||||
return result
|
||||
|
||||
def reconcile_notification_lifecycle(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> Mapping[str, object]:
|
||||
"""Reconcile durable Postbox visibility facts with current IDM holders."""
|
||||
|
||||
db = _session(session)
|
||||
bounded_limit = max(1, min(int(limit), 500))
|
||||
query = db.query(Postbox).filter(Postbox.status == "active")
|
||||
if tenant_id:
|
||||
query = query.filter(Postbox.tenant_id == tenant_id)
|
||||
postboxes = (
|
||||
query.options(
|
||||
selectinload(Postbox.address_record),
|
||||
selectinload(Postbox.bindings),
|
||||
)
|
||||
# Updating the cursor advances ``updated_at`` so bounded worker
|
||||
# batches rotate fairly through large tenants.
|
||||
.order_by(Postbox.updated_at, Postbox.tenant_id, Postbox.id)
|
||||
.with_for_update()
|
||||
.limit(bounded_limit)
|
||||
.all()
|
||||
)
|
||||
result = {
|
||||
"scanned": len(postboxes),
|
||||
"changed": 0,
|
||||
"events": 0,
|
||||
"notifications": 0,
|
||||
"notification_failures": 0,
|
||||
}
|
||||
for effective_tenant_id in tuple(
|
||||
dict.fromkeys(postbox.tenant_id for postbox in postboxes)
|
||||
):
|
||||
tenant_postboxes = tuple(
|
||||
postbox
|
||||
for postbox in postboxes
|
||||
if postbox.tenant_id == effective_tenant_id
|
||||
)
|
||||
holder_cache = self._holder_cache(
|
||||
tenant_id=effective_tenant_id,
|
||||
postboxes=tenant_postboxes,
|
||||
)
|
||||
for postbox in tenant_postboxes:
|
||||
self._reconcile_postbox_notification_lifecycle(
|
||||
db,
|
||||
postbox=postbox,
|
||||
holder_cache=holder_cache,
|
||||
result=result,
|
||||
)
|
||||
db.flush()
|
||||
return result
|
||||
|
||||
def _reconcile_postbox_notification_lifecycle(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
postbox: Postbox,
|
||||
holder_cache: Mapping[
|
||||
str,
|
||||
tuple[OrganizationFunctionAssignmentRef, ...],
|
||||
],
|
||||
result: dict[str, int],
|
||||
) -> None:
|
||||
binding = self._active_binding(postbox)
|
||||
function_id = (
|
||||
binding.function_id
|
||||
if binding is not None
|
||||
else postbox.address_record.function_id
|
||||
)
|
||||
holders = tuple(
|
||||
sorted(
|
||||
self._holders(
|
||||
postbox.tenant_id,
|
||||
function_id,
|
||||
dict(holder_cache),
|
||||
),
|
||||
key=lambda holder: holder.id,
|
||||
)
|
||||
)
|
||||
current = {
|
||||
holder.id: _assignment_notification_snapshot(holder) for holder in holders
|
||||
}
|
||||
settings = _mapping(postbox.settings)
|
||||
cursor = _mapping(settings.get("notification_lifecycle"))
|
||||
holder_items = cursor.get("holders")
|
||||
if not isinstance(holder_items, list):
|
||||
holder_items = []
|
||||
pending_items = cursor.get("pending_notifications")
|
||||
if not isinstance(pending_items, list):
|
||||
pending_items = []
|
||||
previous = {
|
||||
str(item.get("assignment_id")): dict(item)
|
||||
for item in holder_items
|
||||
if isinstance(item, Mapping) and item.get("assignment_id")
|
||||
}
|
||||
pending = [
|
||||
dict(item)
|
||||
for item in pending_items
|
||||
if isinstance(item, Mapping)
|
||||
]
|
||||
|
||||
retained_pending: list[dict[str, object]] = []
|
||||
holders_by_id = {holder.id: holder for holder in holders}
|
||||
handled_recipients: set[tuple[str, str]] = set()
|
||||
for item in pending:
|
||||
assignment_id = str(item.get("assignment_id") or "")
|
||||
holder = holders_by_id.get(assignment_id)
|
||||
if holder is None:
|
||||
continue
|
||||
recipient = _assignment_notification_recipient(holder)
|
||||
if recipient is not None and recipient in handled_recipients:
|
||||
continue
|
||||
if recipient is not None:
|
||||
handled_recipients.add(recipient)
|
||||
outcome = self._notify_assignment_holder(
|
||||
session,
|
||||
postbox=postbox,
|
||||
holder=holder,
|
||||
event_kind=str(
|
||||
item.get("event_kind") or "postbox.assignment.newly_visible.v1"
|
||||
),
|
||||
)
|
||||
if outcome is True:
|
||||
result["notifications"] += 1
|
||||
elif outcome is False:
|
||||
retained_pending.append(item)
|
||||
result["notification_failures"] += 1
|
||||
|
||||
# Existing installations establish a quiet baseline on first run. New
|
||||
# changes after that point are emitted exactly once from this cursor.
|
||||
if int(cursor.get("version") or 0) != 1:
|
||||
settings["notification_lifecycle"] = {
|
||||
"version": 1,
|
||||
"holders": list(current.values()),
|
||||
"vacant": not bool(current),
|
||||
"pending_notifications": retained_pending,
|
||||
"reconciled_at": utc_now().isoformat(),
|
||||
}
|
||||
postbox.settings = settings
|
||||
return
|
||||
|
||||
added_ids = tuple(sorted(set(current) - set(previous)))
|
||||
removed_ids = tuple(sorted(set(previous) - set(current)))
|
||||
was_vacant = bool(cursor.get("vacant"))
|
||||
is_vacant = not bool(current)
|
||||
if added_ids or removed_ids or was_vacant != is_vacant:
|
||||
result["changed"] += 1
|
||||
|
||||
if added_ids or removed_ids:
|
||||
_publish_postbox_event(
|
||||
session,
|
||||
"postbox.assignment.reassigned.v1",
|
||||
tenant_id=postbox.tenant_id,
|
||||
resource_type="postbox",
|
||||
resource_id=postbox.id,
|
||||
postbox_id=postbox.id,
|
||||
actor_type="module",
|
||||
actor_id="postbox",
|
||||
payload={
|
||||
"function_id": function_id,
|
||||
"added_assignment_ids": list(added_ids),
|
||||
"removed_assignment_ids": list(removed_ids),
|
||||
"holder_count": len(current),
|
||||
"vacant": is_vacant,
|
||||
},
|
||||
)
|
||||
result["events"] += 1
|
||||
|
||||
for assignment_id in removed_ids:
|
||||
snapshot = previous[assignment_id]
|
||||
self._record_access_event(
|
||||
session,
|
||||
tenant_id=postbox.tenant_id,
|
||||
postbox_id=postbox.id,
|
||||
actor=None,
|
||||
assignment_id=assignment_id,
|
||||
action="assignment.visibility.withdraw",
|
||||
outcome="denied",
|
||||
reason_code="effective_assignment_ended",
|
||||
details={
|
||||
"function_id": function_id,
|
||||
"assignment_source": snapshot.get("source"),
|
||||
},
|
||||
)
|
||||
_publish_postbox_event(
|
||||
session,
|
||||
"postbox.assignment.visibility_withdrawn.v1",
|
||||
tenant_id=postbox.tenant_id,
|
||||
resource_type="postbox",
|
||||
resource_id=postbox.id,
|
||||
postbox_id=postbox.id,
|
||||
actor_type="module",
|
||||
actor_id="postbox",
|
||||
payload={
|
||||
"function_id": function_id,
|
||||
"assignment_id": assignment_id,
|
||||
"assignment_source": snapshot.get("source"),
|
||||
},
|
||||
)
|
||||
result["events"] += 1
|
||||
|
||||
for assignment_id in added_ids:
|
||||
holder = holders_by_id[assignment_id]
|
||||
event_kind = (
|
||||
"postbox.delegation.newly_visible.v1"
|
||||
if holder.source in {"delegated", "acting_for"}
|
||||
else "postbox.assignment.newly_visible.v1"
|
||||
)
|
||||
self._record_access_event(
|
||||
session,
|
||||
tenant_id=postbox.tenant_id,
|
||||
postbox_id=postbox.id,
|
||||
actor=None,
|
||||
assignment_id=assignment_id,
|
||||
action="assignment.visibility.grant",
|
||||
outcome="allowed",
|
||||
reason_code=f"effective_{holder.source}_assignment",
|
||||
details={
|
||||
"function_id": function_id,
|
||||
"assignment_source": holder.source,
|
||||
},
|
||||
)
|
||||
_publish_postbox_event(
|
||||
session,
|
||||
event_kind,
|
||||
tenant_id=postbox.tenant_id,
|
||||
resource_type="postbox",
|
||||
resource_id=postbox.id,
|
||||
postbox_id=postbox.id,
|
||||
actor_type="module",
|
||||
actor_id="postbox",
|
||||
payload={
|
||||
"function_id": function_id,
|
||||
"assignment_id": assignment_id,
|
||||
"assignment_source": holder.source,
|
||||
},
|
||||
)
|
||||
result["events"] += 1
|
||||
recipient = _assignment_notification_recipient(holder)
|
||||
if recipient is not None and recipient in handled_recipients:
|
||||
outcome = None
|
||||
else:
|
||||
if recipient is not None:
|
||||
handled_recipients.add(recipient)
|
||||
outcome = self._notify_assignment_holder(
|
||||
session,
|
||||
postbox=postbox,
|
||||
holder=holder,
|
||||
event_kind=event_kind,
|
||||
)
|
||||
if outcome is True:
|
||||
result["notifications"] += 1
|
||||
elif outcome is False:
|
||||
retained_pending.append(
|
||||
{
|
||||
"assignment_id": assignment_id,
|
||||
"event_kind": event_kind,
|
||||
}
|
||||
)
|
||||
result["notification_failures"] += 1
|
||||
|
||||
if is_vacant and not was_vacant:
|
||||
_publish_postbox_event(
|
||||
session,
|
||||
"postbox.vacancy.detected.v1",
|
||||
tenant_id=postbox.tenant_id,
|
||||
resource_type="postbox",
|
||||
resource_id=postbox.id,
|
||||
postbox_id=postbox.id,
|
||||
actor_type="module",
|
||||
actor_id="postbox",
|
||||
payload={"function_id": function_id, "holder_count": 0},
|
||||
)
|
||||
result["events"] += 1
|
||||
elif was_vacant and not is_vacant:
|
||||
_publish_postbox_event(
|
||||
session,
|
||||
"postbox.vacancy.resolved.v1",
|
||||
tenant_id=postbox.tenant_id,
|
||||
resource_type="postbox",
|
||||
resource_id=postbox.id,
|
||||
postbox_id=postbox.id,
|
||||
actor_type="module",
|
||||
actor_id="postbox",
|
||||
payload={
|
||||
"function_id": function_id,
|
||||
"holder_count": len(current),
|
||||
},
|
||||
)
|
||||
result["events"] += 1
|
||||
|
||||
settings["notification_lifecycle"] = {
|
||||
"version": 1,
|
||||
"holders": list(current.values()),
|
||||
"vacant": is_vacant,
|
||||
"pending_notifications": retained_pending,
|
||||
"reconciled_at": utc_now().isoformat(),
|
||||
}
|
||||
postbox.settings = settings
|
||||
|
||||
def _notify_assignment_holder(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
postbox: Postbox,
|
||||
holder: OrganizationFunctionAssignmentRef,
|
||||
event_kind: str,
|
||||
) -> bool | None:
|
||||
if self._notifications is None:
|
||||
return None
|
||||
recipient_id = holder.account_id or holder.identity_id
|
||||
if not recipient_id:
|
||||
return None
|
||||
recipient_type = "account" if holder.account_id else "identity"
|
||||
try:
|
||||
with session.begin_nested():
|
||||
self._notifications.enqueue_notification(
|
||||
session,
|
||||
NotificationDispatchRequest(
|
||||
tenant_id=postbox.tenant_id,
|
||||
source_module="postbox",
|
||||
source_resource_type="postbox",
|
||||
source_resource_id=postbox.id,
|
||||
event_kind=event_kind,
|
||||
channel="inbox",
|
||||
recipient_type=recipient_type,
|
||||
recipient_id=recipient_id,
|
||||
subject="Postbox responsibility changed",
|
||||
body_text=(
|
||||
"A Postbox is now available through your current "
|
||||
"function assignment or delegation."
|
||||
),
|
||||
action_url=f"/postbox?postbox={postbox.id}",
|
||||
priority=2,
|
||||
payload={
|
||||
"postbox_id": postbox.id,
|
||||
"classification": postbox.classification,
|
||||
"assignment_id": holder.id,
|
||||
"assignment_source": holder.source,
|
||||
},
|
||||
metadata={
|
||||
"preference_scope": "postbox",
|
||||
"quiet_period_eligible": True,
|
||||
"channel_policy_owner": "notifications",
|
||||
},
|
||||
),
|
||||
enqueue_delivery=False,
|
||||
)
|
||||
return True
|
||||
except Exception: # noqa: BLE001 - lifecycle reconciliation is retryable.
|
||||
logger.warning(
|
||||
"Postbox assignment notification enqueue failed",
|
||||
exc_info=True,
|
||||
extra={
|
||||
"postbox_id": postbox.id,
|
||||
"assignment_id": holder.id,
|
||||
"notification_recipient_id": recipient_id,
|
||||
},
|
||||
)
|
||||
return False
|
||||
|
||||
def _hierarchy_route_plan(
|
||||
self,
|
||||
*,
|
||||
@@ -2376,6 +2780,26 @@ class PostboxService:
|
||||
route: PostboxRoute,
|
||||
now: datetime,
|
||||
) -> tuple[str, bool]:
|
||||
_publish_postbox_event(
|
||||
session,
|
||||
"postbox.escalation.due.v1",
|
||||
tenant_id=route.tenant_id,
|
||||
resource_type="postbox_route",
|
||||
resource_id=route.id,
|
||||
postbox_id=route.target_postbox_id,
|
||||
actor_type="module",
|
||||
actor_id="postbox",
|
||||
payload={
|
||||
"route_id": route.id,
|
||||
"delivery_id": route.delivery_id,
|
||||
"source_postbox_id": route.source_postbox_id,
|
||||
"source_message_id": route.source_message_id,
|
||||
"target_postbox_id": route.target_postbox_id,
|
||||
"execute_after": (
|
||||
route.execute_after.isoformat() if route.execute_after else None
|
||||
),
|
||||
},
|
||||
)
|
||||
if route.source_route_id:
|
||||
previous_route = (
|
||||
session.query(PostboxRoute)
|
||||
@@ -2502,6 +2926,9 @@ class PostboxService:
|
||||
body_text=source_message.body_text,
|
||||
sender_label=source_message.sender_label,
|
||||
classification=source_message.classification,
|
||||
action_required=bool(
|
||||
_mapping(source_message.metadata_).get("action_required")
|
||||
),
|
||||
expires_at=source_message.expires_at,
|
||||
metadata=dict(source_message.metadata_ or {}),
|
||||
)
|
||||
@@ -2511,6 +2938,13 @@ class PostboxService:
|
||||
delivery=delivery,
|
||||
message=message,
|
||||
holders=holders,
|
||||
event_kind="postbox.escalation.due.v1",
|
||||
subject="Postbox escalation requires attention",
|
||||
body_text=(
|
||||
"An escalated Postbox item is available through your current "
|
||||
"function responsibility."
|
||||
),
|
||||
priority=4,
|
||||
)
|
||||
_publish_postbox_event(
|
||||
session,
|
||||
@@ -2625,6 +3059,10 @@ class PostboxService:
|
||||
delivery: PostboxDelivery,
|
||||
message: PostboxMessage,
|
||||
holders: Sequence[OrganizationFunctionAssignmentRef],
|
||||
event_kind: str | None = None,
|
||||
subject: str | None = None,
|
||||
body_text: str | None = None,
|
||||
priority: int | None = None,
|
||||
) -> None:
|
||||
if self._notifications is None:
|
||||
return
|
||||
@@ -2641,6 +3079,12 @@ class PostboxService:
|
||||
("identity", holder.id),
|
||||
)
|
||||
for recipient_id, (recipient_type, assignment_id) in recipients.items():
|
||||
action_required = request.action_required
|
||||
effective_event_kind = event_kind or (
|
||||
"postbox.message.action_required.v1"
|
||||
if action_required
|
||||
else "postbox.delivery.accepted.v1"
|
||||
)
|
||||
try:
|
||||
with session.begin_nested():
|
||||
self._notifications.enqueue_notification(
|
||||
@@ -2650,26 +3094,44 @@ class PostboxService:
|
||||
source_module="postbox",
|
||||
source_resource_type="postbox_message",
|
||||
source_resource_id=message.id,
|
||||
event_kind="postbox.delivery.accepted.v1",
|
||||
event_kind=effective_event_kind,
|
||||
channel="inbox",
|
||||
recipient_type=recipient_type,
|
||||
recipient_id=recipient_id,
|
||||
subject="New Postbox message",
|
||||
body_text=(
|
||||
"A new message is available in one of your "
|
||||
subject=subject
|
||||
or (
|
||||
"Postbox action required"
|
||||
if action_required
|
||||
else "New Postbox message"
|
||||
),
|
||||
body_text=body_text
|
||||
or (
|
||||
"A Postbox item requires action through one of "
|
||||
"your current function responsibilities."
|
||||
if action_required
|
||||
else "A new message is available in one of your "
|
||||
"currently assigned Postboxes."
|
||||
),
|
||||
action_url=f"/postbox?message={message.id}",
|
||||
priority=2,
|
||||
priority=(
|
||||
priority
|
||||
if priority is not None
|
||||
else (4 if action_required else 2)
|
||||
),
|
||||
payload={
|
||||
"postbox_id": message.postbox_id,
|
||||
"message_id": message.id,
|
||||
"delivery_id": delivery.id,
|
||||
"delivery_status": delivery.status,
|
||||
"classification": message.classification,
|
||||
"action_required": action_required,
|
||||
},
|
||||
metadata={
|
||||
"assignment_id": assignment_id,
|
||||
"producer_module": request.producer_module,
|
||||
"preference_scope": "postbox",
|
||||
"quiet_period_eligible": not action_required,
|
||||
"channel_policy_owner": "notifications",
|
||||
},
|
||||
),
|
||||
enqueue_delivery=False,
|
||||
|
||||
Reference in New Issue
Block a user