diff --git a/README.md b/README.md index 1feabe8..24f2255 100644 --- a/README.md +++ b/README.md @@ -43,13 +43,14 @@ The module has one required runtime dependency: - `govoplan-core` for platform services, auth, RBAC, DB/session lifecycle, migrations, and WebUI shell integration -Files, Mail, Distribution Lists, Templates, and Postbox are optional module integrations declared in the campaign manifest: +Files, Mail, Distribution Lists, Templates, Postbox, and Calendar are optional module integrations declared in the campaign manifest: - `govoplan-files` enables managed attachment selection, frozen file-version evidence, and managed-file usage tracking. Server/API campaigns require this integration for attachments and never resolve caller-supplied local filesystem paths. Legacy file-oriented loading remains available only to explicitly trusted operator/library workflows. - `govoplan-mail` owns reusable profiles, encrypted SMTP/IMAP credentials, delivery policy checks, connection tests, and transport execution. Campaign JSON stores only `server.mail_profile_id`; inline transport settings and credentials are rejected. Without Mail, campaigns can still be authored, but profile validation and real delivery are unavailable. - `govoplan-dist-lists` expands reusable governed audiences. Campaign freezes the exact list revision, provider evidence, candidates, and explicit per-recipient primary/fallback route into its own version. - `govoplan-templates` validates and renders published label, envelope, letter, and list-layout templates for postal or internal-mail delivery. Generated output is hash-bound to its template, inputs, actor, route decisions, and Campaign version. - `govoplan-postbox` resolves exact or organization-derived Postbox targets and records provider acceptance and receipt evidence. It remains optional; Mail-only and print-only campaigns do not require it. +- `govoplan-calendar` renders and mirrors individualized VEVENT invitations through the versioned `calendar.invitations` capability. Campaign freezes one METHOD:REQUEST attachment per recipient during build, creates the Calendar mirror only after delivery acceptance, and reads live RSVP state in bounded report batches. Mail may forward METHOD:REPLY parts from an authorized IMAP source. Calendar absence leaves ordinary Campaign authoring and delivery usable. Hybrid delivery never treats an opt-in as an implicit duplicate-send instruction. The Campaign author selects one primary route per recipient and may select a supported fallback. A fallback runs only after the first channel rejects before acceptance; accepted or outcome-unknown effects stop cross-channel retry. Printable output is generated once during build, optionally persisted through Files, reviewed with the exact Campaign version, and accepted idempotently per recipient job during delivery. diff --git a/src/govoplan_campaign/backend/campaign/models.py b/src/govoplan_campaign/backend/campaign/models.py index eddb862..3383397 100644 --- a/src/govoplan_campaign/backend/campaign/models.py +++ b/src/govoplan_campaign/backend/campaign/models.py @@ -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) diff --git a/src/govoplan_campaign/backend/campaign/validation.py b/src/govoplan_campaign/backend/campaign/validation.py index 75920da..c8976da 100644 --- a/src/govoplan_campaign/backend/campaign/validation.py +++ b/src/govoplan_campaign/backend/campaign/validation.py @@ -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)) diff --git a/src/govoplan_campaign/backend/documentation.py b/src/govoplan_campaign/backend/documentation.py index 396d813..dec4ce2 100644 --- a/src/govoplan_campaign/backend/documentation.py +++ b/src/govoplan_campaign/backend/documentation.py @@ -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) diff --git a/src/govoplan_campaign/backend/integrations.py b/src/govoplan_campaign/backend/integrations.py index 532bdfd..1b8a902 100644 --- a/src/govoplan_campaign/backend/integrations.py +++ b/src/govoplan_campaign/backend/integrations.py @@ -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)) diff --git a/src/govoplan_campaign/backend/manifest.py b/src/govoplan_campaign/backend/manifest.py index 88692cb..ce9efed 100644 --- a/src/govoplan_campaign/backend/manifest.py +++ b/src/govoplan_campaign/backend/manifest.py @@ -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", diff --git a/src/govoplan_campaign/backend/persistence/campaigns.py b/src/govoplan_campaign/backend/persistence/campaigns.py index 05256f1..bf6b1f3 100644 --- a/src/govoplan_campaign/backend/persistence/campaigns.py +++ b/src/govoplan_campaign/backend/persistence/campaigns.py @@ -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"]) diff --git a/src/govoplan_campaign/backend/reports/campaigns.py b/src/govoplan_campaign/backend/reports/campaigns.py index 55e5e45..f9221a8 100644 --- a/src/govoplan_campaign/backend/reports/campaigns.py +++ b/src/govoplan_campaign/backend/reports/campaigns.py @@ -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") diff --git a/src/govoplan_campaign/backend/routes/campaigns.py b/src/govoplan_campaign/backend/routes/campaigns.py index 59ff80c..df3bc48 100644 --- a/src/govoplan_campaign/backend/routes/campaigns.py +++ b/src/govoplan_campaign/backend/routes/campaigns.py @@ -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, diff --git a/src/govoplan_campaign/backend/routes/jobs.py b/src/govoplan_campaign/backend/routes/jobs.py index a660245..4186ff2 100644 --- a/src/govoplan_campaign/backend/routes/jobs.py +++ b/src/govoplan_campaign/backend/routes/jobs.py @@ -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, diff --git a/src/govoplan_campaign/backend/schemas.py b/src/govoplan_campaign/backend/schemas.py index 1954d71..5e56bb8 100644 --- a/src/govoplan_campaign/backend/schemas.py +++ b/src/govoplan_campaign/backend/schemas.py @@ -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") diff --git a/src/govoplan_campaign/backend/sending/jobs.py b/src/govoplan_campaign/backend/sending/jobs.py index e7370ee..f9ca370 100644 --- a/src/govoplan_campaign/backend/sending/jobs.py +++ b/src/govoplan_campaign/backend/sending/jobs.py @@ -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( diff --git a/src/govoplan_campaign/backend/services/job_queries.py b/src/govoplan_campaign/backend/services/job_queries.py index a71f857..363db9d 100644 --- a/src/govoplan_campaign/backend/services/job_queries.py +++ b/src/govoplan_campaign/backend/services/job_queries.py @@ -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, diff --git a/tests/test_calendar_invitations.py b/tests/test_calendar_invitations.py new file mode 100644 index 0000000..6cce521 --- /dev/null +++ b/tests/test_calendar_invitations.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import tempfile +import unittest +from email import policy +from email.message import EmailMessage +from email.parser import BytesParser +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from govoplan_campaign.backend.campaign.models import ( + BuildStatus, + CampaignConfig, + SendStatus, +) +from govoplan_campaign.backend.campaign.validation import validate_campaign_config +from govoplan_campaign.backend.messages.builder import BuiltMessage +from govoplan_campaign.backend.messages.models import ( + ImapStatus, + MessageAddress, + MessageDraft, + MessageValidationStatus, +) +from govoplan_campaign.backend.persistence.campaigns import ( + _prepare_built_calendar_invitations, +) +from govoplan_campaign.backend.sending.jobs import _mark_accepted_job_artifacts +from govoplan_core.core.calendar import CalendarInvitationRef + + +def _config() -> CampaignConfig: + return CampaignConfig.model_validate( + { + "version": "1.0", + "campaign": { + "id": "campaign-1", + "name": "Invitation campaign", + "mode": "send", + }, + "template": {"subject": "Planning", "text": "Please reply."}, + "entries": { + "inline": [ + { + "id": "recipient-1", + "name": "Ada", + "to": [{"email": "ada@example.test", "name": "Ada"}], + "fields": {"appointment_start": "2026-08-05T09:00:00"}, + } + ] + }, + "delivery": { + "channel_policy": "mail", + "calendar_invitation": { + "enabled": True, + "calendar_id": "calendar-1", + "summary_template": "Planning with {{name}}", + "start_at_template": "{{appointment_start}}", + "end_at_template": "2026-08-05T10:00:00", + "timezone": "Europe/Berlin", + }, + }, + } + ) + + +class _CalendarIntegration: + available = True + + def request_from_payload(self, payload): + return payload + + def render_invitation(self, request): + assert request["correlation_id"].startswith("campaign:version-1:") + return "\r\n".join( + ( + "BEGIN:VCALENDAR", + "VERSION:2.0", + "METHOD:REQUEST", + "BEGIN:VEVENT", + "UID:invitation-1@govoplan.local", + "SUMMARY:Planning with Ada", + "END:VEVENT", + "END:VCALENDAR", + "", + ) + ) + + +class _MirroringIntegration: + available = True + + def __init__(self) -> None: + self.upsert_count = 0 + + def request_from_payload(self, payload): + return SimpleNamespace( + metadata=payload.get("metadata") or {}, + payload=payload, + ) + + def upsert_invitation(self, _session, **_kwargs): + self.upsert_count += 1 + return CalendarInvitationRef( + event_id="event-1", + calendar_id="calendar-1", + uid="invitation-1@govoplan.local", + correlation_id="campaign:version-1:entry-1", + source_module="campaigns", + source_resource_type="campaign_version", + source_resource_id="version-1", + ) + + +class _Session: + def __init__(self) -> None: + self.added: list[object] = [] + + def add(self, value) -> None: + self.added.append(value) + + +class CampaignCalendarInvitationTests(unittest.TestCase): + def test_validation_requires_calendar_capability(self) -> None: + report = validate_campaign_config(_config(), calendar_available=False) + + self.assertIn( + "calendar_invitation_unavailable", + {issue.code for issue in report.issues}, + ) + + def test_build_freezes_individual_request_and_ics_attachment(self) -> None: + config = _config() + entry = config.entries.inline[0] # type: ignore[index] + message = EmailMessage() + message["From"] = "Organizer " + message["To"] = "Ada " + message["Subject"] = "Planning" + message.set_content("Please reply.") + with tempfile.TemporaryDirectory() as temp_dir: + eml_path = Path(temp_dir) / "message.eml" + eml_path.write_bytes(bytes(message)) + draft = MessageDraft( + entry_index=1, + entry_id="recipient-1", + active=True, + build_status=BuildStatus.BUILT, + validation_status=MessageValidationStatus.READY, + send_status=SendStatus.DRAFT, + imap_status=ImapStatus.NOT_REQUESTED, + subject="Planning", + **{ + "from": MessageAddress( + email="organizer@example.test", + name="Organizer", + ) + }, + to=[MessageAddress(email="ada@example.test", name="Ada")], + eml_path=str(eml_path), + ) + provenance: dict[int, dict[str, object]] = {1: {}} + + with patch( + "govoplan_campaign.backend.persistence.campaigns.calendar_integration", + return_value=_CalendarIntegration(), + ): + _prepare_built_calendar_invitations( + version=SimpleNamespace( + id="version-1", + campaign_id="campaign-1", + ), + config=config, + built_messages=[BuiltMessage(draft=draft, mime=message)], + entries_by_index={1: entry}, + delivery_provenance_by_index=provenance, + user_id="user-1", + ) + + parsed = BytesParser(policy=policy.default).parsebytes( + eml_path.read_bytes() + ) + attachments = list(parsed.iter_attachments()) + self.assertEqual("text/calendar", attachments[0].get_content_type()) + self.assertEqual("REQUEST", attachments[0].get_param("method")) + invitation = provenance[1]["calendar_invitation"] + self.assertEqual("prepared", invitation["state"]) + self.assertEqual( + "2026-08-05T09:00:00+02:00", + invitation["request"]["start_at"], + ) + + def test_delivery_acceptance_mirrors_once_without_reopening_delivery(self) -> None: + integration = _MirroringIntegration() + job = SimpleNamespace( + tenant_id="tenant-1", + delivery_provenance={ + "calendar_invitation": { + "state": "prepared", + "request": { + "metadata": {"prepared_by_user_id": "user-1"}, + }, + } + }, + ) + session = _Session() + + with ( + patch( + "govoplan_campaign.backend.sending.jobs.calendar_integration", + return_value=integration, + ), + patch( + "govoplan_campaign.backend.sending.jobs.files_integration" + ), + ): + _mark_accepted_job_artifacts(session, job) + _mark_accepted_job_artifacts(session, job) + + invitation = job.delivery_provenance["calendar_invitation"] + self.assertEqual("mirrored", invitation["state"]) + self.assertEqual("event-1", invitation["event_id"]) + self.assertEqual(1, integration.upsert_count) + self.assertEqual([job], session.added) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/src/api/campaigns.ts b/webui/src/api/campaigns.ts index 372314a..bac075e 100644 --- a/webui/src/api/campaigns.ts +++ b/webui/src/api/campaigns.ts @@ -368,6 +368,21 @@ export type CampaignPostboxCatalog = { organization_units: CampaignPostboxOrganizationUnit[]; }; +export type CampaignCalendarCatalogEntry = { + id: string; + name: string; + color?: string | null; + timezone: string; + source_kind: string; + writable: boolean; +}; + +export type CampaignCalendarCatalog = { + available: boolean; + reason?: string | null; + calendars: CampaignCalendarCatalogEntry[]; +}; + export type CampaignPrintTemplateOutputProfile = { id: string; label: string; @@ -945,6 +960,13 @@ campaignId: string) return apiFetch(settings, `/api/v1/campaigns/${campaignId}/postbox-catalog`); } +export async function getCampaignCalendarInvitationCatalog( +settings: ApiSettings, +campaignId: string) +: Promise { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/calendar-invitation-catalog`); +} + export async function listCampaignPrintTemplates( settings: ApiSettings, campaignId: string, diff --git a/webui/src/features/campaigns/CampaignReportPage.tsx b/webui/src/features/campaigns/CampaignReportPage.tsx index cddc885..12d0d3c 100644 --- a/webui/src/features/campaigns/CampaignReportPage.tsx +++ b/webui/src/features/campaigns/CampaignReportPage.tsx @@ -388,6 +388,16 @@ export default function CampaignReportPage({ settings, campaignId }: {settings: { id: "queue", header: "i18n:govoplan-campaign.queue.d325fcd9", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: QUEUE_STATUS_OPTIONS, display: "pill" }, render: (row) => , value: (row) => String(row.queue_status ?? "unknown") }, { id: "send", header: "Delivery", width: 160, sortable: true, filterable: true, columnType: "from-list", list: { options: SEND_STATUS_OPTIONS, display: "pill" }, render: (row) => , value: (row) => String(row.send_status ?? "unknown") }, { id: "postbox", header: "Postbox", width: 155, sortable: true, filterable: true, columnType: "from-list", list: { options: POSTBOX_STATUS_OPTIONS, display: "pill" }, render: (row) => , value: (row) => String(row.postbox_status ?? "unknown") }, + { + id: "rsvp", + header: "RSVP", + width: 145, + value: (row) => calendarRsvpStatus(row), + render: (row) => { + const status = calendarRsvpStatus(row); + return status === "—" ? : ; + } + }, { id: "print", header: "Print", width: 135, sortable: true, filterable: true, columnType: "from-list", list: { options: PRINT_STATUS_OPTIONS, display: "pill" }, render: (row) => , value: (row) => String(row.print_status ?? "unknown") }, { id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: IMAP_STATUS_OPTIONS, display: "pill" }, render: (row) => , value: (row) => String(row.imap_status ?? "unknown") }, { id: "attempts", header: "i18n:govoplan-campaign.attempts.5a29585e", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => Number(row.attempt_count ?? 0), render: (row) => String(Number(row.attempt_count ?? 0) + Number(row.postbox_attempt_count ?? 0)) }, @@ -603,6 +613,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
i18n:govoplan-campaign.smtp_state.ff372566
Postbox state
Postbox targets
{String(detail.job.postbox_target_count ?? 0)}
+
Calendar RSVP
{calendarRsvpStatus(detail.job)}
Print state
i18n:govoplan-campaign.imap_state.03b83be0
i18n:govoplan-campaign.attachments.6771ade6
{String(detail.job.matched_file_count ?? detail.job.attachment_count ?? 0)}
@@ -1037,6 +1048,11 @@ function deliveryStatusLabel(status: string): string | undefined { return status === "skipped" ? "i18n:govoplan-campaign.skipped.5a000ad7" : undefined; } +function calendarRsvpStatus(row: Record): string { + const invitation = asRecord(row.calendar_invitation); + return String(invitation.rsvp_status || "—"); +} + function initialReportQuery(): string { if (typeof window === "undefined") return ""; return new URLSearchParams(window.location.search).get("q")?.trim() ?? ""; diff --git a/webui/src/features/campaigns/GlobalSettingsPage.tsx b/webui/src/features/campaigns/GlobalSettingsPage.tsx index 067cd98..e9eeef4 100644 --- a/webui/src/features/campaigns/GlobalSettingsPage.tsx +++ b/webui/src/features/campaigns/GlobalSettingsPage.tsx @@ -1,7 +1,9 @@ import { useEffect, useMemo, useState } from "react"; import type { ApiSettings, AuthInfo } from "../../types"; import { + getCampaignCalendarInvitationCatalog, getCampaignPostboxCatalog, + type CampaignCalendarCatalog, type CampaignPostboxCatalog } from "../../api/campaigns"; import { Button } from "@govoplan/core-webui"; @@ -49,8 +51,14 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view = templates: [], organization_units: [] }); + const [calendarCatalog, setCalendarCatalog] = useState({ + available: false, + calendars: [] + }); const [postboxTargetsOpen, setPostboxTargetsOpen] = useState(false); const postboxModuleInstalled = usePlatformModuleInstalled("postbox"); + const calendarModuleInstalled = usePlatformModuleInstalled("calendar"); + const mailModuleInstalled = usePlatformModuleInstalled("mail"); const isPolicyView = view === "policy"; const version = data.currentVersion; @@ -77,6 +85,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view = const rateLimit = asRecord(delivery.rate_limit); const retry = asRecord(delivery.retry); const postboxDelivery = asRecord(delivery.postbox); + const calendarInvitation = asRecord(delivery.calendar_invitation); const fieldDefinitions = useMemo( () => getDraftFields(displayDraft), [displayDraft] @@ -123,6 +132,40 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view = settings.apiKey ]); + useEffect(() => { + if (!calendarModuleInstalled) { + setCalendarCatalog({ + available: false, + reason: "The Calendar module is not active.", + calendars: [] + }); + return; + } + let cancelled = false; + void getCampaignCalendarInvitationCatalog(settings, campaignId) + .then((catalog) => { + if (!cancelled) setCalendarCatalog(catalog); + }) + .catch((cause) => { + if (!cancelled) { + setCalendarCatalog({ + available: false, + reason: cause instanceof Error ? cause.message : String(cause), + calendars: [] + }); + } + }); + return () => { + cancelled = true; + }; + }, [ + campaignId, + calendarModuleInstalled, + settings.accessToken, + settings.apiBaseUrl, + settings.apiKey + ]); + function patchEditor(path: string[], value: unknown) { if (locked) return; setEditorState((current) => updateNested(current, path, value)); @@ -329,6 +372,107 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view = } + +
+ patch(["delivery", "calendar_invitation", "enabled"], checked)} + /> + + + + + patch(["delivery", "calendar_invitation", "summary_template"], event.target.value)} + /> + + + patch(["delivery", "calendar_invitation", "start_at_template"], event.target.value)} + /> + + + patch(["delivery", "calendar_invitation", "end_at_template"], event.target.value)} + /> + + + patch(["delivery", "calendar_invitation", "timezone"], event.target.value)} + /> + + + patch(["delivery", "calendar_invitation", "location_template"], event.target.value)} + /> + + + patch( + ["delivery", "calendar_invitation", "categories"], + event.target.value.split(",").map((value) => value.trim()).filter(Boolean) + )} + /> + + +