Add bulk calendar invitation delivery
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <organizer@example.test>"
|
||||
message["To"] = "Ada <ada@example.test>"
|
||||
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()
|
||||
@@ -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<CampaignPostboxCatalog>(settings, `/api/v1/campaigns/${campaignId}/postbox-catalog`);
|
||||
}
|
||||
|
||||
export async function getCampaignCalendarInvitationCatalog(
|
||||
settings: ApiSettings,
|
||||
campaignId: string)
|
||||
: Promise<CampaignCalendarCatalog> {
|
||||
return apiFetch<CampaignCalendarCatalog>(settings, `/api/v1/campaigns/${campaignId}/calendar-invitation-catalog`);
|
||||
}
|
||||
|
||||
export async function listCampaignPrintTemplates(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
|
||||
@@ -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) => <StatusBadge status={String(row.queue_status ?? "unknown")} />, 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) => <StatusBadge status={String(row.send_status ?? "unknown")} label={deliveryStatusLabel(String(row.send_status ?? "unknown"))} />, 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) => <StatusBadge status={String(row.postbox_status ?? "unknown")} label={deliveryStatusLabel(String(row.postbox_status ?? "unknown"))} />, 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 === "—" ? <span className="muted">—</span> : <StatusBadge status={status.toLowerCase()} label={humanize(status)} />;
|
||||
}
|
||||
},
|
||||
{ id: "print", header: "Print", width: 135, sortable: true, filterable: true, columnType: "from-list", list: { options: PRINT_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.print_status ?? "unknown")} label={deliveryStatusLabel(String(row.print_status ?? "unknown"))} />, 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) => <StatusBadge status={String(row.imap_status ?? "unknown")} label={deliveryStatusLabel(String(row.imap_status ?? "unknown"))} />, 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:
|
||||
<div><dt>i18n:govoplan-campaign.smtp_state.ff372566</dt><dd><StatusBadge status={String(detail.job.send_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.send_status ?? "unknown"))} /></dd></div>
|
||||
<div><dt>Postbox state</dt><dd><StatusBadge status={String(detail.job.postbox_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.postbox_status ?? "unknown"))} /></dd></div>
|
||||
<div><dt>Postbox targets</dt><dd>{String(detail.job.postbox_target_count ?? 0)}</dd></div>
|
||||
<div><dt>Calendar RSVP</dt><dd>{calendarRsvpStatus(detail.job)}</dd></div>
|
||||
<div><dt>Print state</dt><dd><StatusBadge status={String(detail.job.print_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.print_status ?? "unknown"))} /></dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.imap_state.03b83be0</dt><dd><StatusBadge status={String(detail.job.imap_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.imap_status ?? "unknown"))} /></dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.attachments.6771ade6</dt><dd>{String(detail.job.matched_file_count ?? detail.job.attachment_count ?? 0)}</dd></div>
|
||||
@@ -1037,6 +1048,11 @@ function deliveryStatusLabel(status: string): string | undefined {
|
||||
return status === "skipped" ? "i18n:govoplan-campaign.skipped.5a000ad7" : undefined;
|
||||
}
|
||||
|
||||
function calendarRsvpStatus(row: Record<string, unknown>): 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() ?? "";
|
||||
|
||||
@@ -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<CampaignCalendarCatalog>({
|
||||
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 =
|
||||
}
|
||||
</Card>
|
||||
|
||||
<Card title="Calendar invitations" collapsible>
|
||||
<div className="form-grid compact responsive-form-grid">
|
||||
<ToggleSwitch
|
||||
label="Send individualized invitations"
|
||||
checked={getBool(calendarInvitation, "enabled")}
|
||||
disabled={locked || !calendarCatalog.available || !mailModuleInstalled}
|
||||
onChange={(checked) => patch(["delivery", "calendar_invitation", "enabled"], checked)}
|
||||
/>
|
||||
<FormField label="Tracking calendar">
|
||||
<select
|
||||
value={getText(calendarInvitation, "calendar_id")}
|
||||
disabled={locked || !calendarCatalog.available}
|
||||
onChange={(event) => patch(["delivery", "calendar_invitation", "calendar_id"], event.target.value || null)}
|
||||
>
|
||||
<option value="">Select a calendar</option>
|
||||
{calendarCatalog.calendars.map((calendar) =>
|
||||
<option key={calendar.id} value={calendar.id} disabled={!calendar.writable}>
|
||||
{calendar.name}{calendar.writable ? "" : " (read-only)"}
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Summary template">
|
||||
<input
|
||||
value={getText(calendarInvitation, "summary_template")}
|
||||
disabled={locked}
|
||||
placeholder="Appointment with {{name}}"
|
||||
onChange={(event) => patch(["delivery", "calendar_invitation", "summary_template"], event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Start (ISO 8601 or field template)">
|
||||
<input
|
||||
value={getText(calendarInvitation, "start_at_template")}
|
||||
disabled={locked}
|
||||
placeholder="2026-08-12T10:00 or {{appointment_start}}"
|
||||
onChange={(event) => patch(["delivery", "calendar_invitation", "start_at_template"], event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="End (ISO 8601 or field template)">
|
||||
<input
|
||||
value={getText(calendarInvitation, "end_at_template")}
|
||||
disabled={locked}
|
||||
placeholder="2026-08-12T11:00 or {{appointment_end}}"
|
||||
onChange={(event) => patch(["delivery", "calendar_invitation", "end_at_template"], event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Timezone">
|
||||
<input
|
||||
value={getText(calendarInvitation, "timezone", "UTC")}
|
||||
disabled={locked}
|
||||
placeholder="Europe/Berlin"
|
||||
onChange={(event) => patch(["delivery", "calendar_invitation", "timezone"], event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Location template">
|
||||
<input
|
||||
value={getText(calendarInvitation, "location_template")}
|
||||
disabled={locked}
|
||||
onChange={(event) => patch(["delivery", "calendar_invitation", "location_template"], event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Categories">
|
||||
<input
|
||||
value={(Array.isArray(calendarInvitation.categories) ? calendarInvitation.categories : []).join(", ")}
|
||||
disabled={locked}
|
||||
placeholder="Invitation, Campaign"
|
||||
onChange={(event) => patch(
|
||||
["delivery", "calendar_invitation", "categories"],
|
||||
event.target.value.split(",").map((value) => value.trim()).filter(Boolean)
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Description template">
|
||||
<textarea
|
||||
rows={3}
|
||||
value={getText(calendarInvitation, "description_template")}
|
||||
disabled={locked}
|
||||
onChange={(event) => patch(["delivery", "calendar_invitation", "description_template"], event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
{!mailModuleInstalled &&
|
||||
<DismissibleAlert tone="warning" dismissible={false}>
|
||||
Mail must be active to deliver iCalendar invitations.
|
||||
</DismissibleAlert>
|
||||
}
|
||||
{!calendarCatalog.available &&
|
||||
<DismissibleAlert tone="warning" dismissible={false}>
|
||||
{calendarCatalog.reason || "Calendar invitation tracking is unavailable."}
|
||||
</DismissibleAlert>
|
||||
}
|
||||
{calendarCatalog.available && calendarCatalog.calendars.every((item) => !item.writable) &&
|
||||
<DismissibleAlert tone="warning" dismissible={false}>
|
||||
No writable calendar is available for invitation tracking.
|
||||
</DismissibleAlert>
|
||||
}
|
||||
<p className="muted small-note">
|
||||
One METHOD:REQUEST attachment is frozen per recipient at build time. The Calendar mirror is created after delivery acceptance; attendee replies are shown in Campaign reports.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card title="i18n:govoplan-campaign.opt_ins_and_local_assistance.d0d23635" collapsible>
|
||||
<div className="toggle-grid">
|
||||
<ToggleSwitch label="i18n:govoplan-campaign.suggest_addresses_from_this_campaign.5ebe2aea" checked={getBool(optIns, "campaign_address_suggestions", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "campaign_address_suggestions"], checked)} />
|
||||
|
||||
Reference in New Issue
Block a user