Add bulk calendar invitation delivery

This commit is contained in:
2026-08-02 16:38:48 +02:00
parent 7733265cc8
commit bf6e07f307
17 changed files with 1356 additions and 20 deletions
@@ -719,10 +719,26 @@ class PrintDeliveryConfig(StrictModel):
persist_to_files: bool = True
class CalendarInvitationDeliveryConfig(StrictModel):
enabled: bool = False
calendar_id: str | None = Field(default=None, max_length=36)
summary_template: str | None = Field(default=None, max_length=2_000)
description_template: str | None = Field(default=None, max_length=20_000)
location_template: str | None = Field(default=None, max_length=2_000)
start_at_template: str | None = Field(default=None, max_length=1_000)
end_at_template: str | None = Field(default=None, max_length=1_000)
timezone: str | None = Field(default=None, max_length=100)
classification: Literal["PUBLIC", "PRIVATE", "CONFIDENTIAL"] = "PUBLIC"
categories: list[str] = Field(default_factory=list, max_length=50)
class DeliveryConfig(StrictModel):
channel_policy: DeliveryChannelPolicy = DeliveryChannelPolicy.MAIL
postbox: PostboxDeliveryConfig = Field(default_factory=PostboxDeliveryConfig)
print: PrintDeliveryConfig = Field(default_factory=PrintDeliveryConfig)
calendar_invitation: CalendarInvitationDeliveryConfig = Field(
default_factory=CalendarInvitationDeliveryConfig
)
rate_limit: RateLimitConfig = Field(default_factory=RateLimitConfig)
imap_append_sent: ImapAppendSentConfig = Field(default_factory=ImapAppendSentConfig)
retry: RetryConfig = Field(default_factory=RetryConfig)
@@ -2,9 +2,11 @@ from __future__ import annotations
import csv
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
from pathlib import Path
from typing import Iterable
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import BaseModel, ConfigDict, Field
@@ -483,6 +485,7 @@ def _delivery_issues(
*,
postbox_available: bool,
templates_available: bool,
calendar_available: bool,
) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
policies = _delivery_policies(config)
@@ -574,9 +577,110 @@ def _delivery_issues(
f"/entries/inline/{entry_index}/print_target",
)
)
invitation = config.delivery.calendar_invitation
if invitation.enabled:
if not calendar_available:
issues.append(
_issue(
Severity.ERROR,
"calendar_invitation_unavailable",
"Calendar invitations require the optional Calendar module.",
"/delivery/calendar_invitation/enabled",
)
)
if not policies or any(not policy.uses_mail for policy in policies):
issues.append(
_issue(
Severity.ERROR,
"calendar_invitation_requires_mail",
"Calendar invitations require Mail delivery for every active recipient.",
"/delivery/channel_policy",
)
)
if not invitation.calendar_id:
issues.append(
_issue(
Severity.ERROR,
"calendar_invitation_calendar_missing",
"Select a writable calendar for campaign invitation tracking.",
"/delivery/calendar_invitation/calendar_id",
)
)
if not invitation.start_at_template:
issues.append(
_issue(
Severity.ERROR,
"calendar_invitation_start_missing",
"Calendar invitations require a start date and time or a field template.",
"/delivery/calendar_invitation/start_at_template",
)
)
if invitation.timezone:
try:
ZoneInfo(invitation.timezone)
except ZoneInfoNotFoundError:
issues.append(
_issue(
Severity.ERROR,
"calendar_invitation_timezone_invalid",
f"Unknown calendar invitation timezone: {invitation.timezone}",
"/delivery/calendar_invitation/timezone",
)
)
valid_start, fixed_start = _fixed_invitation_datetime(
invitation.start_at_template
)
valid_end, fixed_end = _fixed_invitation_datetime(invitation.end_at_template)
if invitation.start_at_template and not valid_start:
issues.append(
_issue(
Severity.ERROR,
"calendar_invitation_start_invalid",
"The fixed invitation start must be ISO 8601; recipient field templates are also supported.",
"/delivery/calendar_invitation/start_at_template",
)
)
if invitation.end_at_template and not valid_end:
issues.append(
_issue(
Severity.ERROR,
"calendar_invitation_end_invalid",
"The fixed invitation end must be ISO 8601; recipient field templates are also supported.",
"/delivery/calendar_invitation/end_at_template",
)
)
if fixed_start and fixed_end and _invitation_range_invalid(
fixed_start,
fixed_end,
):
issues.append(
_issue(
Severity.ERROR,
"calendar_invitation_range_invalid",
"Calendar invitation end must be after its start.",
"/delivery/calendar_invitation/end_at_template",
)
)
return issues
def _fixed_invitation_datetime(value: str | None) -> tuple[bool, datetime | None]:
if not value:
return True, None
if "${" in value or "{{" in value:
return True, None
try:
return True, datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return False, None
def _invitation_range_invalid(start_at: datetime, end_at: datetime) -> bool:
if (start_at.tzinfo is None) != (end_at.tzinfo is None):
return True
return end_at <= start_at
def _sender_issues(config: CampaignConfig) -> list[SemanticIssue]:
"""Require Campaign-owned sender data before a send-mode build."""
@@ -826,6 +930,7 @@ def validate_campaign_config(
check_files: bool = False,
postbox_available: bool = False,
templates_available: bool = False,
calendar_available: bool = False,
) -> SemanticReport:
campaign_path = Path(campaign_file).resolve() if campaign_file else Path.cwd() / "campaign.json"
issues: list[SemanticIssue] = []
@@ -842,6 +947,7 @@ def validate_campaign_config(
config,
postbox_available=postbox_available,
templates_available=templates_available,
calendar_available=calendar_available,
)
)
issues.extend(_sender_issues(config))
@@ -47,6 +47,7 @@ _DISTRIBUTION_LIST_SOURCE_INTEGRATION = "dist_lists.source"
_DISTRIBUTION_LIST_EXPAND_INTEGRATION = "dist_lists.expand"
_TEMPLATE_CATALOG_INTEGRATION = "templates.catalog"
_TEMPLATE_RENDERER_INTEGRATION = "templates.renderer"
_CALENDAR_INVITATION_INTEGRATION = "calendar.invitations"
_NOTIFICATIONS_INTEGRATION = "notifications.dispatch"
@@ -340,6 +341,43 @@ CAMPAIGN_USER_DOCUMENTATION = (
),
related_modules=("files",),
),
_workflow_topic(
topic_id="campaigns.workflow.send-calendar-invitations",
title="Send individualized calendar invitations",
summary="Freeze one iCalendar request per recipient, deliver it through Mail, and review live attendee answers from Calendar.",
body="Campaign owns the recipient expansion, exact invitation request, message delivery evidence, and report. Calendar owns the mirrored VEVENT and attendee answer state. The mirror is created only after a delivery channel accepts the message; a Calendar failure never rewrites accepted Mail evidence. Mail can forward METHOD:REPLY parts from a configured IMAP delivery-status source.",
order=35,
audience=("campaign_manager", "campaign_sender"),
required_modules=("campaigns", "mail", "calendar"),
required_capabilities=(_MAIL_INTEGRATION, _CALENDAR_INVITATION_INTEGRATION),
required_scopes=(
"campaigns:campaign:read",
"campaigns:campaign:update",
"campaigns:campaign:build",
"campaigns:campaign:send",
"campaigns:recipient:read",
"calendar:calendar:read",
),
route="/campaigns/{campaign_id}/global-settings",
screen="Campaign settings",
help_contexts=("campaign.global-settings", "campaign.report"),
prerequisites=(
"Mail and Calendar are active and you can select a writable calendar.",
"Every queueable recipient has a Mail To address and the invitation start template resolves to ISO 8601.",
),
steps=(
"Open Campaign settings, enable individualized Calendar invitations, and select the tracking calendar.",
"Enter summary, start, optional end, timezone, location, description, and category templates.",
"Validate and build; inspect the frozen METHOD:REQUEST attachment for each recipient before delivery.",
"Deliver the reviewed build and use Report to compare Mail delivery with the live RSVP state.",
"Configure a Mail IMAP delivery-status source when inbound METHOD:REPLY reconciliation is required.",
),
outcome="Individually delivered invitations with correlated Calendar events and recipient-level RSVP reporting.",
verification="The Campaign job shows accepted delivery, a mirrored Calendar event ID, and the current attendee status; repeated mailbox ingestion does not duplicate the response effect.",
related_topic_ids=("campaigns.workflow.prepare-validate-and-build", "campaigns.workflow.view-delivery-report"),
related_modules=("mail", "calendar"),
limitations=("Recurring Campaign invitation series require a separate series workflow; this slice creates individual VEVENT requests."),
),
_workflow_topic(
topic_id="campaigns.workflow.queue-delivery",
title="Queue a campaign for controlled delivery",
@@ -751,6 +789,23 @@ def _integration_summary(registry: object, principal: object) -> tuple[tuple[str
else:
limitations.append("Automatic in-app Campaign status notifications are not configured.")
calendar_available = _integration_available(
registry,
_CALENDAR_INVITATION_INTEGRATION,
)
if calendar_available and mail_available:
configured.append(
"Installed composition: Campaign can freeze individualized iCalendar requests, mirror accepted deliveries into Calendar, and report live attendee answers."
)
elif calendar_available:
limitations.append(
"Calendar invitation tracking is active, but Mail is not available to deliver Campaign invitation messages."
)
else:
limitations.append(
"Calendar-backed invitation and RSVP tracking is not available in this composition."
)
return tuple(configured), tuple(limitations)
@@ -14,6 +14,14 @@ from govoplan_core.core.approvals import (
ApprovalRequestRef,
CAPABILITY_APPROVAL_REQUESTS,
)
from govoplan_core.core.calendar import (
CAPABILITY_CALENDAR_INVITATIONS,
CalendarInvitationAttendeeRequest,
CalendarInvitationCalendarRef,
CalendarInvitationProvider,
CalendarInvitationRef,
CalendarInvitationRequest,
)
from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_DELIVERY,
@@ -49,6 +57,7 @@ POSTBOX_EVIDENCE_CAPABILITY = CAPABILITY_POSTBOX_EVIDENCE
APPROVALS_CAPABILITY = CAPABILITY_APPROVAL_REQUESTS
TEMPLATE_CATALOG_CAPABILITY = CAPABILITY_TEMPLATE_CATALOG
TEMPLATE_RENDERER_CAPABILITY = CAPABILITY_TEMPLATE_RENDERER
CALENDAR_INVITATIONS_CAPABILITY = CAPABILITY_CALENDAR_INVITATIONS
class OptionalModuleUnavailable(RuntimeError):
@@ -105,6 +114,10 @@ class TemplateOutputUnavailable(OptionalModuleUnavailable):
pass
class CalendarInvitationUnavailable(OptionalModuleUnavailable):
pass
class _PreparedCampaignSnapshot:
def __init__(self, directory: Path, path: Path, raw_json: dict[str, Any]) -> None:
self._directory = directory
@@ -605,6 +618,163 @@ class TemplatesCampaignIntegration:
return self._renderer.render(session, principal, request=request)
class CalendarCampaignIntegration:
def __init__(self, delegate: object | None = None) -> None:
self._delegate = (
delegate if isinstance(delegate, CalendarInvitationProvider) else None
)
@property
def available(self) -> bool:
return self._delegate is not None
def list_calendars(
self,
session: object,
*,
tenant_id: str,
user_id: str | None,
group_ids: tuple[str, ...] = (),
can_admin: bool = False,
) -> tuple[CalendarInvitationCalendarRef, ...]:
if self._delegate is None:
return ()
return tuple(
self._delegate.list_calendars(
session,
tenant_id=tenant_id,
user_id=user_id,
group_ids=group_ids,
can_admin=can_admin,
)
)
def render_invitation(self, request: CalendarInvitationRequest) -> str:
if self._delegate is None:
raise CalendarInvitationUnavailable(
"Calendar invitations require the optional Calendar module."
)
return self._delegate.render_invitation(request)
def upsert_invitation(
self,
session: object,
*,
tenant_id: str,
user_id: str | None,
request: CalendarInvitationRequest,
) -> CalendarInvitationRef:
if self._delegate is None:
raise CalendarInvitationUnavailable(
"Calendar invitations require the optional Calendar module."
)
return self._delegate.upsert_invitation(
session,
tenant_id=tenant_id,
user_id=user_id,
request=request,
)
def get_invitations(
self,
session: object,
*,
tenant_id: str,
correlation_ids: tuple[str, ...],
) -> dict[str, CalendarInvitationRef]:
if self._delegate is None or not correlation_ids:
return {}
result: dict[str, CalendarInvitationRef] = {}
unique_ids = tuple(dict.fromkeys(correlation_ids))
for offset in range(0, len(unique_ids), 500):
result.update(
self._delegate.get_invitations(
session,
tenant_id=tenant_id,
correlation_ids=unique_ids[offset : offset + 500],
)
)
return result
def summarize_invitations(
self,
session: object,
*,
tenant_id: str,
source_resource_id: str | None,
) -> dict[str, object]:
if self._delegate is None:
return {
"available": False,
"reason": "The Calendar invitation capability is not active.",
}
return dict(
self._delegate.summarize_invitations(
session,
tenant_id=tenant_id,
source_module="campaigns",
source_resource_type="campaign_version",
source_resource_id=source_resource_id,
)
)
@staticmethod
def request_from_payload(payload: dict[str, Any]) -> CalendarInvitationRequest:
from datetime import datetime
attendees = tuple(
CalendarInvitationAttendeeRequest(
address=str(item.get("address") or ""),
name=str(item["name"]) if item.get("name") else None,
role=str(item.get("role") or "REQ-PARTICIPANT"),
participation_status=str(
item.get("participation_status") or "NEEDS-ACTION"
),
rsvp=bool(item.get("rsvp", True)),
)
for item in payload.get("attendees") or []
if isinstance(item, dict)
)
return CalendarInvitationRequest(
correlation_id=str(payload.get("correlation_id") or ""),
source_module="campaigns",
source_resource_type="campaign_version",
source_resource_id=(
str(payload["source_resource_id"])
if payload.get("source_resource_id")
else None
),
calendar_id=(
str(payload["calendar_id"]) if payload.get("calendar_id") else None
),
summary=str(payload.get("summary") or ""),
description=(
str(payload["description"]) if payload.get("description") else None
),
location=str(payload["location"]) if payload.get("location") else None,
start_at=datetime.fromisoformat(str(payload.get("start_at") or "")),
end_at=(
datetime.fromisoformat(str(payload["end_at"]))
if payload.get("end_at")
else None
),
timezone=str(payload["timezone"]) if payload.get("timezone") else None,
organizer=(
dict(payload["organizer"])
if isinstance(payload.get("organizer"), dict)
else None
),
attendees=attendees,
classification=str(payload.get("classification") or "PUBLIC"),
categories=tuple(str(value) for value in payload.get("categories") or []),
metadata=(
dict(payload["metadata"])
if isinstance(payload.get("metadata"), dict)
else {}
),
)
def files_integration() -> FilesCampaignIntegration:
return FilesCampaignIntegration(capability(FILES_CAPABILITY))
@@ -630,3 +800,7 @@ def templates_integration() -> TemplatesCampaignIntegration:
capability(TEMPLATE_CATALOG_CAPABILITY),
capability(TEMPLATE_RENDERER_CAPABILITY),
)
def calendar_integration() -> CalendarCampaignIntegration:
return CalendarCampaignIntegration(capability(CALENDAR_INVITATIONS_CAPABILITY))
@@ -14,6 +14,7 @@ from govoplan_core.core.campaigns import (
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT,
CAPABILITY_CAMPAIGNS_RETENTION,
)
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_INVITATIONS
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
@@ -373,6 +374,7 @@ manifest = ModuleManifest(
"addresses",
"dist_lists",
"templates",
"calendar",
"postbox",
"approvals",
"reporting",
@@ -449,6 +451,12 @@ manifest = ModuleManifest(
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name=CAPABILITY_CALENDAR_INVITATIONS,
version_min="0.2.0",
version_max_exclusive="0.3.0",
optional=True,
),
ModuleInterfaceRequirement(
name=CAPABILITY_POSTBOX_DELIVERY,
version_min="0.1.1",
@@ -11,6 +11,7 @@ from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any
from uuid import uuid4
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from sqlalchemy import func
from sqlalchemy.orm import Session
@@ -58,6 +59,7 @@ from govoplan_campaign.backend.campaign.postbox_targets import (
from govoplan_campaign.backend.campaign.field_values import (
effective_entry_field_values,
)
from govoplan_campaign.backend.campaign.template_values import build_template_values
from govoplan_campaign.backend.messages.builder import build_campaign_messages
from govoplan_campaign.backend.messages.models import (
MessageDraft,
@@ -73,11 +75,14 @@ from govoplan_campaign.backend.campaign.models import (
SendStatus,
)
from govoplan_campaign.backend.integrations import (
CalendarInvitationUnavailable,
calendar_integration,
files_integration,
mail_integration,
postbox_integration,
templates_integration,
)
from govoplan_campaign.backend.template_rendering import render_template
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
from govoplan_campaign.backend.runtime import get_settings
@@ -564,6 +569,71 @@ def _print_template_compatibility_issues(
]
def _calendar_invitation_compatibility_issues(
session: Session,
*,
tenant_id: str,
principal: ApiPrincipal | None,
config: CampaignConfig,
) -> list[SemanticIssue]:
invitation = config.delivery.calendar_invitation
if not invitation.enabled or not invitation.calendar_id:
return []
integration = calendar_integration()
if not integration.available:
return []
if principal is None:
return [
SemanticIssue(
severity=Severity.ERROR,
code="calendar_invitation_principal_missing",
message="Calendar invitations must be validated by an authenticated Campaign actor.",
path="/delivery/calendar_invitation/calendar_id",
)
]
if not any(
principal.has(scope)
for scope in (
"calendar:calendar:read",
"calendar:calendar:write",
"calendar:calendar:admin",
)
):
return [
SemanticIssue(
severity=Severity.ERROR,
code="calendar_invitation_calendar_forbidden",
message="You are not permitted to select a calendar for invitation tracking.",
path="/delivery/calendar_invitation/calendar_id",
)
]
calendars = integration.list_calendars(
session,
tenant_id=tenant_id,
user_id=str(getattr(principal.user, "id", "")) or None,
group_ids=tuple(principal.group_ids),
can_admin=principal.has("calendar:calendar:admin"),
)
selected = next(
(item for item in calendars if item.id == invitation.calendar_id),
None,
)
if selected is None:
message = "The selected invitation calendar is not visible or no longer exists."
elif not selected.writable:
message = "The selected invitation calendar is read-only."
else:
return []
return [
SemanticIssue(
severity=Severity.ERROR,
code="calendar_invitation_calendar_unavailable",
message=message,
path="/delivery/calendar_invitation/calendar_id",
)
]
def validate_campaign_version(
session: Session,
*,
@@ -623,6 +693,7 @@ def validate_campaign_version(
check_files=True,
postbox_available=postbox_integration().available,
templates_available=templates_integration().available,
calendar_available=calendar_integration().available,
)
else:
report = validate_campaign_config(
@@ -631,6 +702,7 @@ def validate_campaign_version(
check_files=False,
postbox_available=postbox_integration().available,
templates_available=templates_integration().available,
calendar_available=calendar_integration().available,
)
report.issues.extend(
_print_template_compatibility_issues(
@@ -639,6 +711,14 @@ def validate_campaign_version(
config=config,
)
)
report.issues.extend(
_calendar_invitation_compatibility_issues(
session,
tenant_id=tenant_id,
principal=principal,
config=config,
)
)
report_json = report.model_dump(mode="json")
report_json.update(
{
@@ -825,6 +905,192 @@ def _resolve_built_postbox_targets(
return resolved_by_index
def _render_invitation_value(
template: str | None,
values: dict[str, Any],
*,
fallback: str | None = None,
) -> str | None:
if template is None:
return fallback
rendered = render_template(template, values, keep_missing=False).strip()
return rendered or fallback
def _invitation_datetime(value: str, *, timezone_id: str | None) -> datetime:
try:
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
except ValueError as exc:
raise CampaignPersistenceError(
f"Calendar invitation date is not valid ISO 8601: {value!r}"
) from exc
if parsed.tzinfo is not None:
return parsed
try:
return parsed.replace(tzinfo=ZoneInfo(timezone_id or "UTC"))
except ZoneInfoNotFoundError as exc:
raise CampaignPersistenceError(
f"Calendar invitation timezone is unknown: {timezone_id}"
) from exc
def _calendar_invitation_request_payload(
*,
version: CampaignVersion,
config: CampaignConfig,
built: Any,
entry: Any,
user_id: str | None,
) -> dict[str, Any]:
invitation = config.delivery.calendar_invitation
values = build_template_values(config, entry)
start_text = _render_invitation_value(
invitation.start_at_template,
values,
)
if not start_text:
raise CampaignPersistenceError(
"Calendar invitation start resolved to an empty value."
)
end_text = _render_invitation_value(invitation.end_at_template, values)
start_at = _invitation_datetime(start_text, timezone_id=invitation.timezone)
end_at = (
_invitation_datetime(end_text, timezone_id=invitation.timezone)
if end_text
else None
)
if end_at is not None and end_at <= start_at:
raise CampaignPersistenceError(
"Calendar invitation end must be after its start."
)
attendee_by_address = {
item.email.strip().casefold(): item
for item in built.draft.to
if item.email.strip()
}
if not attendee_by_address:
raise CampaignPersistenceError(
"Calendar invitation has no effective To recipient."
)
sender = built.draft.from_
organizer = None
if sender is not None and sender.email.strip():
params: dict[str, list[str]] = {}
if sender.name:
params["CN"] = [sender.name]
organizer = {
"value": f"mailto:{sender.email.strip()}",
"params": params,
}
entry_key = str(built.draft.entry_id or built.draft.entry_index)
correlation_digest = hashlib.sha256(
f"{version.id}:{entry_key}".encode("utf-8")
).hexdigest()[:32]
return {
"correlation_id": f"campaign:{version.id}:{correlation_digest}",
"source_resource_id": version.id,
"calendar_id": invitation.calendar_id,
"summary": _render_invitation_value(
invitation.summary_template,
values,
fallback=built.draft.subject or config.campaign.name,
),
"description": _render_invitation_value(
invitation.description_template,
values,
),
"location": _render_invitation_value(
invitation.location_template,
values,
),
"start_at": start_at.isoformat(),
"end_at": end_at.isoformat() if end_at else None,
"timezone": invitation.timezone,
"organizer": organizer,
"attendees": [
{
"address": item.email.strip(),
"name": item.name,
"role": "REQ-PARTICIPANT",
"participation_status": "NEEDS-ACTION",
"rsvp": True,
}
for item in attendee_by_address.values()
],
"classification": invitation.classification,
"categories": list(invitation.categories),
"metadata": {
"campaign_id": version.campaign_id,
"campaign_version_id": version.id,
"entry_id": built.draft.entry_id,
"entry_index": built.draft.entry_index,
"prepared_by_user_id": user_id,
},
}
def _prepare_built_calendar_invitations(
*,
version: CampaignVersion,
config: CampaignConfig,
built_messages: list[Any],
entries_by_index: dict[int, Any],
delivery_provenance_by_index: dict[int, dict[str, Any]],
user_id: str | None,
) -> None:
if not config.delivery.calendar_invitation.enabled:
return
integration = calendar_integration()
if not integration.available:
raise CampaignPersistenceError(
"Calendar invitation capability became unavailable after validation."
)
for built in built_messages:
if built.mime is None or not built.draft.is_queueable:
continue
entry = entries_by_index.get(built.draft.entry_index)
if entry is None:
raise CampaignPersistenceError(
"Built invitation recipient is missing from the campaign input."
)
request_payload = _calendar_invitation_request_payload(
version=version,
config=config,
built=built,
entry=entry,
user_id=user_id,
)
request = integration.request_from_payload(request_payload)
try:
icalendar = integration.render_invitation(request)
except (CalendarInvitationUnavailable, ValueError) as exc:
raise CampaignPersistenceError(str(exc)) from exc
built.mime.add_attachment(
icalendar,
subtype="calendar",
charset="utf-8",
filename="invitation.ics",
params={"method": "REQUEST"},
)
if built.draft.eml_path:
path = Path(built.draft.eml_path)
path.write_bytes(bytes(built.mime))
built.draft.eml_size_bytes = path.stat().st_size
built.draft.attachment_count += 1
provenance = delivery_provenance_by_index.setdefault(
built.draft.entry_index,
{},
)
provenance["calendar_invitation"] = {
"state": "prepared",
"request": request_payload,
"icalendar_sha256": hashlib.sha256(
icalendar.encode("utf-8")
).hexdigest(),
"prepared_at": datetime.now(UTC).isoformat(),
}
def _print_render_item(
config: CampaignConfig,
entry: Any,
@@ -1295,6 +1561,14 @@ def build_campaign_version(
built_messages=result.built_messages,
entries_by_index=entries_by_index,
)
_prepare_built_calendar_invitations(
version=version,
config=managed_config,
built_messages=result.built_messages,
entries_by_index=entries_by_index,
delivery_provenance_by_index=delivery_provenance_by_index,
user_id=user_id,
)
new_print_storage_keys = sorted(
{
str(artifact["storage_key"])
@@ -30,7 +30,7 @@ from govoplan_campaign.backend.db.models import (
PostboxDeliveryAttempt,
SendAttempt,
)
from govoplan_campaign.backend.integrations import postbox_integration
from govoplan_campaign.backend.integrations import calendar_integration, postbox_integration
from govoplan_campaign.backend.runtime import capability
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot
from govoplan_campaign.backend.response_security import (
@@ -38,6 +38,10 @@ from govoplan_campaign.backend.response_security import (
public_delivery_result_message,
public_source_filename,
)
from govoplan_campaign.backend.services.job_queries import (
_calendar_invitation_state_from_provenance,
_calendar_invitations_for_jobs,
)
class CampaignReportError(RuntimeError):
@@ -586,7 +590,9 @@ def _job_row(
*,
include_diagnostics: bool = False,
review_decision: dict[str, Any] | None = None,
calendar_invitation: dict[str, object] | None = None,
) -> dict[str, Any]:
invitation = calendar_invitation or _calendar_invitation_state_from_provenance(job)
row = {
"job_id": job.id,
"entry_index": job.entry_index,
@@ -639,6 +645,10 @@ def _job_row(
review_decision,
include_diagnostics=include_diagnostics,
),
"calendar_invitation": invitation,
"calendar_rsvp_status": (
invitation.get("rsvp_status") if invitation is not None else None
),
}
if include_diagnostics:
row.update(
@@ -713,13 +723,16 @@ def _job_evidence_row(
latest_message_action: CampaignMessageAction | None = None,
include_diagnostics: bool = False,
review_decision: dict[str, Any] | None = None,
calendar_invitation: dict[str, object] | None = None,
) -> dict[str, Any]:
row = _job_row(
job,
include_diagnostics=include_diagnostics,
review_decision=review_decision,
calendar_invitation=calendar_invitation,
)
public_decision = row.pop("review_decision") or {}
invitation = row.pop("calendar_invitation") or {}
recipients = job.resolved_recipients or {}
row.update({
"campaign_id": job.campaign_id,
@@ -752,6 +765,13 @@ def _job_evidence_row(
str(code)
for code in public_decision.get("issue_codes") or []
),
"calendar_invitation_state": invitation.get("state"),
"calendar_rsvp_status": invitation.get("rsvp_status"),
"calendar_event_id": invitation.get("event_id"),
"calendar_id": invitation.get("calendar_id"),
"calendar_uid": invitation.get("uid"),
"calendar_external_state": invitation.get("external_state"),
"calendar_reply_ingress": invitation.get("reply_ingress"),
})
if include_diagnostics:
row.update(
@@ -870,6 +890,7 @@ def generate_campaign_report(
)
aggregate = _JobReportAggregate()
job_rows: list[dict[str, Any]] = []
included_jobs: list[CampaignJob] = []
review_decisions = _review_decisions_by_job(version)
retry_max_attempts = _retry_max_attempts(version)
iterator = jobs.yield_per(500) if hasattr(jobs, "yield_per") else jobs
@@ -880,19 +901,13 @@ def generate_campaign_report(
include_recent_failures=include_recent_failures,
)
if include_jobs:
if len(job_rows) >= CAMPAIGN_JSON_JOB_LIMIT:
if len(included_jobs) >= CAMPAIGN_JSON_JOB_LIMIT:
raise CampaignReportError(
"The recipient-level JSON report exceeds the safe row "
f"limit of {CAMPAIGN_JSON_JOB_LIMIT}; use the CSV export "
"or the paginated recipient table."
)
job_rows.append(
_job_row(
job,
include_diagnostics=include_diagnostics,
review_decision=review_decisions.get(job.id),
)
)
included_jobs.append(job)
report = _campaign_report_payload(
session,
campaign=campaign,
@@ -904,6 +919,19 @@ def generate_campaign_report(
review_decisions=review_decisions,
)
if include_jobs:
calendar_invitations = _calendar_invitations_for_jobs(
session,
included_jobs,
)
job_rows.extend(
_job_row(
job,
include_diagnostics=include_diagnostics,
review_decision=review_decisions.get(job.id),
calendar_invitation=calendar_invitations.get(job.id),
)
for job in included_jobs
)
report["jobs"] = job_rows
return report
@@ -943,6 +971,11 @@ def _campaign_report_payload(
campaign_id=campaign.id,
version=version,
)
calendar_invitations = calendar_integration().summarize_invitations(
session,
tenant_id=tenant_id,
source_resource_id=version.id if version else None,
)
report = {
"generated_at": _utcnow_iso(),
"campaign": _campaign_report_campaign_payload(campaign),
@@ -982,6 +1015,7 @@ def _campaign_report_payload(
version=version,
),
"postbox_receipts": postbox_receipts,
"calendar_invitations": calendar_invitations,
"message_actions": _campaign_message_action_summary(
session,
tenant_id=tenant_id,
@@ -1620,6 +1654,7 @@ def generate_jobs_csv(
job_ids,
)
review_decisions = _review_decisions_by_job(version)
calendar_invitations = _calendar_invitations_for_jobs(session, jobs)
rows = [
_job_evidence_row(
job,
@@ -1628,6 +1663,7 @@ def generate_jobs_csv(
latest_message_action=latest_message_actions.get(job.id),
include_diagnostics=include_diagnostics,
review_decision=review_decisions.get(job.id),
calendar_invitation=calendar_invitations.get(job.id),
)
for job in jobs
]
@@ -1705,6 +1741,13 @@ def generate_jobs_csv(
"review_actor_user_id",
"review_decided_at",
"review_issue_codes",
"calendar_invitation_state",
"calendar_rsvp_status",
"calendar_event_id",
"calendar_id",
"calendar_uid",
"calendar_external_state",
"calendar_reply_ingress",
]
if include_diagnostics:
sent_at_index = fieldnames.index("outcome_unknown_at")
@@ -14,6 +14,7 @@ from govoplan_campaign.backend.schemas import (
CampaignCreateMinimalRequest,
CampaignAddressLookupCandidate,
CampaignAddressLookupResponse,
CampaignCalendarCatalogResponse,
CampaignPostboxCatalogResponse,
CampaignRecipientAddressSource,
CampaignRecipientAddressSourcesResponse,
@@ -63,6 +64,7 @@ from govoplan_campaign.backend.campaign.postbox_targets import (
delivery_catalog_payload,
)
from govoplan_campaign.backend.integrations import (
calendar_integration,
PostboxDeliveryUnavailable,
postbox_integration,
templates_integration,
@@ -756,6 +758,55 @@ def campaign_postbox_catalog(
)
@router.get(
"/{campaign_id}/calendar-invitation-catalog",
response_model=CampaignCalendarCatalogResponse,
)
def campaign_calendar_invitation_catalog(
campaign_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
integration = calendar_integration()
if not integration.available:
return CampaignCalendarCatalogResponse(
reason="The Calendar invitation capability is not active."
)
if not any(
principal.has(scope)
for scope in (
"calendar:calendar:read",
"calendar:calendar:write",
"calendar:calendar:admin",
)
):
return CampaignCalendarCatalogResponse(
reason="You are not permitted to select a Calendar collection."
)
calendars = integration.list_calendars(
session,
tenant_id=principal.tenant_id,
user_id=str(getattr(principal.user, "id", "")) or None,
group_ids=tuple(principal.group_ids),
can_admin=principal.has("calendar:calendar:admin"),
)
return CampaignCalendarCatalogResponse(
available=True,
calendars=[
{
"id": item.id,
"name": item.name,
"color": item.color,
"timezone": item.timezone,
"source_kind": item.source_kind,
"writable": item.writable,
}
for item in calendars
],
)
@router.get("/{campaign_id}/print-templates")
def campaign_print_templates(
campaign_id: str,
+8 -1
View File
@@ -46,6 +46,7 @@ from govoplan_campaign.backend.services.job_queries import (
_campaign_jobs_page_response,
_campaign_jobs_query_context,
_job_attempts_payload,
_calendar_invitations_for_jobs,
_job_detail_payload,
_job_diagnostics_payload,
)
@@ -422,7 +423,13 @@ def get_job_detail(
else []
)
return CampaignJobDetailResponse(
job=_job_detail_payload(job),
job=_job_detail_payload(
job,
calendar_invitation=_calendar_invitations_for_jobs(
session,
[job],
).get(job.id),
),
attempts=_job_attempts_payload(
send_attempts,
imap_attempts,
+6
View File
@@ -396,6 +396,12 @@ class CampaignPostboxCatalogResponse(BaseModel):
organization_units: list[dict[str, Any]] = Field(default_factory=list)
class CampaignCalendarCatalogResponse(BaseModel):
available: bool = False
reason: str | None = None
calendars: list[dict[str, Any]] = Field(default_factory=list)
class CampaignRecipientAddressSourceSnapshotRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
+76 -6
View File
@@ -73,6 +73,7 @@ from govoplan_campaign.backend.integrations import (
MailProfileError,
SmtpConfigurationError,
SmtpSendError,
calendar_integration,
files_integration,
mail_integration,
)
@@ -86,6 +87,75 @@ class SendJobError(RuntimeError):
pass
def _mark_accepted_job_artifacts(session: Session, job: CampaignJob) -> None:
files_integration().mark_job_attachment_uses_sent(session, job)
provenance = (
dict(job.delivery_provenance)
if isinstance(job.delivery_provenance, dict)
else {}
)
invitation = provenance.get("calendar_invitation")
if not isinstance(invitation, dict) or invitation.get("state") == "mirrored":
return
request_payload = invitation.get("request")
if not isinstance(request_payload, dict):
return
integration = calendar_integration()
next_invitation = dict(invitation)
if not integration.available:
next_invitation.update(
{
"state": "unavailable",
"last_error": (
"The Calendar invitation capability is not active; Mail "
"delivery was accepted without a Calendar mirror."
),
}
)
else:
try:
request = integration.request_from_payload(request_payload)
ref = integration.upsert_invitation(
session,
tenant_id=job.tenant_id,
user_id=(
str(request.metadata.get("prepared_by_user_id"))
if request.metadata.get("prepared_by_user_id")
else None
),
request=request,
)
except Exception as exc: # noqa: BLE001 - delivery must remain accepted.
next_invitation.update(
{
"state": "mirror_failed",
"last_error": (
"Calendar mirror failed after delivery acceptance "
f"({type(exc).__name__})."
),
}
)
else:
next_invitation.update(
{
"state": "mirrored",
"event_id": ref.event_id,
"calendar_id": ref.calendar_id,
"uid": ref.uid,
"external_state": ref.external_state,
"outbox_operation_id": ref.outbox_operation_id,
"reply_ingress": ref.reply_ingress,
"recurrence_supported": ref.recurrence_supported,
"degraded_reasons": list(ref.degraded_reasons),
"mirrored_at": datetime.now(timezone.utc).isoformat(),
"last_error": None,
}
)
provenance["calendar_invitation"] = next_invitation
job.delivery_provenance = provenance
session.add(job)
class SynchronousSendRejected(QueueingError):
def __init__(
self,
@@ -2148,7 +2218,7 @@ def _send_single_message_direct(
if delivery_context.snapshot.delivery.imap_append_sent.enabled
else JobImapStatus.NOT_REQUESTED.value
)
files_integration().mark_job_attachment_uses_sent(session, job)
_mark_accepted_job_artifacts(session, job)
session.add(job)
if campaign.current_version_id == version.id:
_update_campaign_after_job(
@@ -2342,7 +2412,7 @@ def reconcile_job_outcome(
if snapshot.delivery.imap_append_sent.enabled
else JobImapStatus.NOT_REQUESTED.value
)
files_integration().mark_job_attachment_uses_sent(session, job)
_mark_accepted_job_artifacts(session, job)
attempt_status = "reconciled_smtp_accepted"
elif decision == "not_sent":
job.send_status = JobSendStatus.FAILED_TEMPORARY.value
@@ -2535,7 +2605,7 @@ def _reconcile_imap_append_outcome(
if decision == "imap_appended":
job.imap_status = JobImapStatus.APPENDED.value
attempt_status = "reconciled_imap_appended"
files_integration().mark_job_attachment_uses_sent(session, job)
_mark_accepted_job_artifacts(session, job)
elif decision == "imap_not_appended":
# FAILED is deliberately retryable only after this explicit,
# evidence-backed operator decision.
@@ -3532,7 +3602,7 @@ def _finalize_multichannel_job(
)
):
job.sent_at = job.sent_at or _utcnow()
files_integration().mark_job_attachment_uses_sent(session, job)
_mark_accepted_job_artifacts(session, job)
if not (mail and mail.accepted):
job.imap_status = JobImapStatus.NOT_REQUESTED.value
session.add(job)
@@ -3719,7 +3789,7 @@ def _record_smtp_send_success(
else JobImapStatus.NOT_REQUESTED.value
)
job.last_error = refused_warning
files_integration().mark_job_attachment_uses_sent(session, job)
_mark_accepted_job_artifacts(session, job)
session.add(attempt)
session.add(job)
_update_campaign_after_job(session, job.campaign_id, job.campaign_version_id)
@@ -4061,7 +4131,7 @@ def _record_imap_append_success(
attempt.folder = folder
attempt.error_message = None
session.add(attempt)
files_integration().mark_job_attachment_uses_sent(session, job)
_mark_accepted_job_artifacts(session, job)
session.commit()
session.expire_all()
return AppendSentResult(
@@ -14,6 +14,7 @@ from govoplan_campaign.backend.schemas import (
)
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.postbox import PostboxDeliveryReceiptSummaryRef
from govoplan_core.core.calendar import CalendarInvitationRef
from govoplan_core.core.change_sequence import (
encode_sequence_watermark,
max_sequence_id,
@@ -49,6 +50,7 @@ from govoplan_campaign.backend.response_security import (
public_campaign_payload,
public_delivery_result_message,
)
from govoplan_campaign.backend.integrations import calendar_integration
from govoplan_campaign.backend.route_support import (
@@ -70,6 +72,7 @@ def _job_summary_payload(
job: CampaignJob,
*,
reviewed_keys: set[str] | None = None,
calendar_invitation: dict[str, object] | None = None,
) -> dict[str, object]:
review_key = _job_review_key(job)
return {
@@ -116,12 +119,18 @@ def _job_summary_payload(
for item in (job.resolved_attachments or [])
if isinstance(item, dict)
),
"calendar_invitation": calendar_invitation
or _calendar_invitation_state_from_provenance(job),
}
def _job_detail_payload(job: CampaignJob) -> dict[str, object]:
def _job_detail_payload(
job: CampaignJob,
*,
calendar_invitation: dict[str, object] | None = None,
) -> dict[str, object]:
return {
**_job_summary_payload(job),
**_job_summary_payload(job, calendar_invitation=calendar_invitation),
"message_id_header": job.message_id_header,
"issues": job.issues_snapshot or [],
"attachments": public_campaign_payload(job.resolved_attachments or []),
@@ -135,6 +144,105 @@ def _job_detail_payload(job: CampaignJob) -> dict[str, object]:
}
def _calendar_invitation_state_from_provenance(
job: CampaignJob,
) -> dict[str, object] | None:
provenance = (
job.delivery_provenance
if isinstance(getattr(job, "delivery_provenance", None), dict)
else {}
)
invitation = provenance.get("calendar_invitation")
if not isinstance(invitation, dict):
return None
request = invitation.get("request")
request_payload = request if isinstance(request, dict) else {}
return {
"available": False,
"state": str(invitation.get("state") or "prepared"),
"correlation_id": request_payload.get("correlation_id"),
"calendar_id": invitation.get("calendar_id")
or request_payload.get("calendar_id"),
"uid": invitation.get("uid"),
"rsvp_status": "NEEDS-ACTION",
"attendees": request_payload.get("attendees") or [],
"last_error": invitation.get("last_error"),
"degraded_reasons": invitation.get("degraded_reasons") or [],
}
def _calendar_invitation_payload(ref: CalendarInvitationRef) -> dict[str, object]:
attendees = [dict(item) for item in ref.attendees]
statuses = [_calendar_attendee_status(item) for item in attendees]
unique_statuses = sorted(set(statuses))
rsvp_status = unique_statuses[0] if len(unique_statuses) == 1 else "MIXED"
return {
"available": True,
"state": "mirrored",
"correlation_id": ref.correlation_id,
"event_id": ref.event_id,
"calendar_id": ref.calendar_id,
"uid": ref.uid,
"rsvp_status": rsvp_status,
"attendees": attendees,
"external_state": ref.external_state,
"outbox_operation_id": ref.outbox_operation_id,
"reply_ingress": ref.reply_ingress,
"recurrence_supported": ref.recurrence_supported,
"degraded_reasons": list(ref.degraded_reasons),
}
def _calendar_attendee_status(attendee: Mapping[str, object]) -> str:
params = attendee.get("params")
if not isinstance(params, Mapping):
return "NEEDS-ACTION"
value = params.get("PARTSTAT")
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
value = value[0] if value else None
return str(value or "NEEDS-ACTION").upper()
def _calendar_invitations_for_jobs(
session: Session,
jobs: Sequence[CampaignJob],
) -> dict[str, dict[str, object]]:
correlations_by_job: dict[str, str] = {}
for job in jobs:
provenance = (
job.delivery_provenance
if isinstance(getattr(job, "delivery_provenance", None), dict)
else {}
)
invitation = provenance.get("calendar_invitation")
request = invitation.get("request") if isinstance(invitation, dict) else None
correlation_id = (
str(request.get("correlation_id") or "").strip()
if isinstance(request, dict)
else ""
)
if correlation_id:
correlations_by_job[job.id] = correlation_id
if not correlations_by_job:
return {}
integration = calendar_integration()
if not integration.available:
return {}
try:
refs = integration.get_invitations(
session,
tenant_id=jobs[0].tenant_id,
correlation_ids=tuple(correlations_by_job.values()),
)
except Exception: # Calendar reporting must not hide Campaign evidence.
return {}
return {
job_id: _calendar_invitation_payload(refs[correlation_id])
for job_id, correlation_id in correlations_by_job.items()
if correlation_id in refs
}
def _job_attempts_payload(
send_attempts: list[SendAttempt],
imap_attempts: list[ImapAppendAttempt],
@@ -766,8 +874,16 @@ def _campaign_jobs_page_response(
)
if changed_job_ids is not None:
jobs = [job for job in jobs if job.id in changed_job_ids]
calendar_invitations = _calendar_invitations_for_jobs(session, jobs)
return CampaignJobsResponse(
jobs=[_job_summary_payload(job, reviewed_keys=reviewed_keys) for job in jobs],
jobs=[
_job_summary_payload(
job,
reviewed_keys=reviewed_keys,
calendar_invitation=calendar_invitations.get(job.id),
)
for job in jobs
],
page=page,
page_size=page_size,
total=total,