Add bulk calendar invitation delivery

This commit is contained in:
2026-08-02 16:38:48 +02:00
parent 7733265cc8
commit bf6e07f307
17 changed files with 1356 additions and 20 deletions
+2 -1
View File
@@ -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,
+8 -1
View File
@@ -46,6 +46,7 @@ from govoplan_campaign.backend.services.job_queries import (
_campaign_jobs_page_response,
_campaign_jobs_query_context,
_job_attempts_payload,
_calendar_invitations_for_jobs,
_job_detail_payload,
_job_diagnostics_payload,
)
@@ -422,7 +423,13 @@ def get_job_detail(
else []
)
return CampaignJobDetailResponse(
job=_job_detail_payload(job),
job=_job_detail_payload(
job,
calendar_invitation=_calendar_invitations_for_jobs(
session,
[job],
).get(job.id),
),
attempts=_job_attempts_payload(
send_attempts,
imap_attempts,
+6
View File
@@ -396,6 +396,12 @@ class CampaignPostboxCatalogResponse(BaseModel):
organization_units: list[dict[str, Any]] = Field(default_factory=list)
class CampaignCalendarCatalogResponse(BaseModel):
available: bool = False
reason: str | None = None
calendars: list[dict[str, Any]] = Field(default_factory=list)
class CampaignRecipientAddressSourceSnapshotRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
+76 -6
View File
@@ -73,6 +73,7 @@ from govoplan_campaign.backend.integrations import (
MailProfileError,
SmtpConfigurationError,
SmtpSendError,
calendar_integration,
files_integration,
mail_integration,
)
@@ -86,6 +87,75 @@ class SendJobError(RuntimeError):
pass
def _mark_accepted_job_artifacts(session: Session, job: CampaignJob) -> None:
files_integration().mark_job_attachment_uses_sent(session, job)
provenance = (
dict(job.delivery_provenance)
if isinstance(job.delivery_provenance, dict)
else {}
)
invitation = provenance.get("calendar_invitation")
if not isinstance(invitation, dict) or invitation.get("state") == "mirrored":
return
request_payload = invitation.get("request")
if not isinstance(request_payload, dict):
return
integration = calendar_integration()
next_invitation = dict(invitation)
if not integration.available:
next_invitation.update(
{
"state": "unavailable",
"last_error": (
"The Calendar invitation capability is not active; Mail "
"delivery was accepted without a Calendar mirror."
),
}
)
else:
try:
request = integration.request_from_payload(request_payload)
ref = integration.upsert_invitation(
session,
tenant_id=job.tenant_id,
user_id=(
str(request.metadata.get("prepared_by_user_id"))
if request.metadata.get("prepared_by_user_id")
else None
),
request=request,
)
except Exception as exc: # noqa: BLE001 - delivery must remain accepted.
next_invitation.update(
{
"state": "mirror_failed",
"last_error": (
"Calendar mirror failed after delivery acceptance "
f"({type(exc).__name__})."
),
}
)
else:
next_invitation.update(
{
"state": "mirrored",
"event_id": ref.event_id,
"calendar_id": ref.calendar_id,
"uid": ref.uid,
"external_state": ref.external_state,
"outbox_operation_id": ref.outbox_operation_id,
"reply_ingress": ref.reply_ingress,
"recurrence_supported": ref.recurrence_supported,
"degraded_reasons": list(ref.degraded_reasons),
"mirrored_at": datetime.now(timezone.utc).isoformat(),
"last_error": None,
}
)
provenance["calendar_invitation"] = next_invitation
job.delivery_provenance = provenance
session.add(job)
class SynchronousSendRejected(QueueingError):
def __init__(
self,
@@ -2148,7 +2218,7 @@ def _send_single_message_direct(
if delivery_context.snapshot.delivery.imap_append_sent.enabled
else JobImapStatus.NOT_REQUESTED.value
)
files_integration().mark_job_attachment_uses_sent(session, job)
_mark_accepted_job_artifacts(session, job)
session.add(job)
if campaign.current_version_id == version.id:
_update_campaign_after_job(
@@ -2342,7 +2412,7 @@ def reconcile_job_outcome(
if snapshot.delivery.imap_append_sent.enabled
else JobImapStatus.NOT_REQUESTED.value
)
files_integration().mark_job_attachment_uses_sent(session, job)
_mark_accepted_job_artifacts(session, job)
attempt_status = "reconciled_smtp_accepted"
elif decision == "not_sent":
job.send_status = JobSendStatus.FAILED_TEMPORARY.value
@@ -2535,7 +2605,7 @@ def _reconcile_imap_append_outcome(
if decision == "imap_appended":
job.imap_status = JobImapStatus.APPENDED.value
attempt_status = "reconciled_imap_appended"
files_integration().mark_job_attachment_uses_sent(session, job)
_mark_accepted_job_artifacts(session, job)
elif decision == "imap_not_appended":
# FAILED is deliberately retryable only after this explicit,
# evidence-backed operator decision.
@@ -3532,7 +3602,7 @@ def _finalize_multichannel_job(
)
):
job.sent_at = job.sent_at or _utcnow()
files_integration().mark_job_attachment_uses_sent(session, job)
_mark_accepted_job_artifacts(session, job)
if not (mail and mail.accepted):
job.imap_status = JobImapStatus.NOT_REQUESTED.value
session.add(job)
@@ -3719,7 +3789,7 @@ def _record_smtp_send_success(
else JobImapStatus.NOT_REQUESTED.value
)
job.last_error = refused_warning
files_integration().mark_job_attachment_uses_sent(session, job)
_mark_accepted_job_artifacts(session, job)
session.add(attempt)
session.add(job)
_update_campaign_after_job(session, job.campaign_id, job.campaign_version_id)
@@ -4061,7 +4131,7 @@ def _record_imap_append_success(
attempt.folder = folder
attempt.error_message = None
session.add(attempt)
files_integration().mark_job_attachment_uses_sent(session, job)
_mark_accepted_job_artifacts(session, job)
session.commit()
session.expire_all()
return AppendSentResult(
@@ -14,6 +14,7 @@ from govoplan_campaign.backend.schemas import (
)
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.postbox import PostboxDeliveryReceiptSummaryRef
from govoplan_core.core.calendar import CalendarInvitationRef
from govoplan_core.core.change_sequence import (
encode_sequence_watermark,
max_sequence_id,
@@ -49,6 +50,7 @@ from govoplan_campaign.backend.response_security import (
public_campaign_payload,
public_delivery_result_message,
)
from govoplan_campaign.backend.integrations import calendar_integration
from govoplan_campaign.backend.route_support import (
@@ -70,6 +72,7 @@ def _job_summary_payload(
job: CampaignJob,
*,
reviewed_keys: set[str] | None = None,
calendar_invitation: dict[str, object] | None = None,
) -> dict[str, object]:
review_key = _job_review_key(job)
return {
@@ -116,12 +119,18 @@ def _job_summary_payload(
for item in (job.resolved_attachments or [])
if isinstance(item, dict)
),
"calendar_invitation": calendar_invitation
or _calendar_invitation_state_from_provenance(job),
}
def _job_detail_payload(job: CampaignJob) -> dict[str, object]:
def _job_detail_payload(
job: CampaignJob,
*,
calendar_invitation: dict[str, object] | None = None,
) -> dict[str, object]:
return {
**_job_summary_payload(job),
**_job_summary_payload(job, calendar_invitation=calendar_invitation),
"message_id_header": job.message_id_header,
"issues": job.issues_snapshot or [],
"attachments": public_campaign_payload(job.resolved_attachments or []),
@@ -135,6 +144,105 @@ def _job_detail_payload(job: CampaignJob) -> dict[str, object]:
}
def _calendar_invitation_state_from_provenance(
job: CampaignJob,
) -> dict[str, object] | None:
provenance = (
job.delivery_provenance
if isinstance(getattr(job, "delivery_provenance", None), dict)
else {}
)
invitation = provenance.get("calendar_invitation")
if not isinstance(invitation, dict):
return None
request = invitation.get("request")
request_payload = request if isinstance(request, dict) else {}
return {
"available": False,
"state": str(invitation.get("state") or "prepared"),
"correlation_id": request_payload.get("correlation_id"),
"calendar_id": invitation.get("calendar_id")
or request_payload.get("calendar_id"),
"uid": invitation.get("uid"),
"rsvp_status": "NEEDS-ACTION",
"attendees": request_payload.get("attendees") or [],
"last_error": invitation.get("last_error"),
"degraded_reasons": invitation.get("degraded_reasons") or [],
}
def _calendar_invitation_payload(ref: CalendarInvitationRef) -> dict[str, object]:
attendees = [dict(item) for item in ref.attendees]
statuses = [_calendar_attendee_status(item) for item in attendees]
unique_statuses = sorted(set(statuses))
rsvp_status = unique_statuses[0] if len(unique_statuses) == 1 else "MIXED"
return {
"available": True,
"state": "mirrored",
"correlation_id": ref.correlation_id,
"event_id": ref.event_id,
"calendar_id": ref.calendar_id,
"uid": ref.uid,
"rsvp_status": rsvp_status,
"attendees": attendees,
"external_state": ref.external_state,
"outbox_operation_id": ref.outbox_operation_id,
"reply_ingress": ref.reply_ingress,
"recurrence_supported": ref.recurrence_supported,
"degraded_reasons": list(ref.degraded_reasons),
}
def _calendar_attendee_status(attendee: Mapping[str, object]) -> str:
params = attendee.get("params")
if not isinstance(params, Mapping):
return "NEEDS-ACTION"
value = params.get("PARTSTAT")
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
value = value[0] if value else None
return str(value or "NEEDS-ACTION").upper()
def _calendar_invitations_for_jobs(
session: Session,
jobs: Sequence[CampaignJob],
) -> dict[str, dict[str, object]]:
correlations_by_job: dict[str, str] = {}
for job in jobs:
provenance = (
job.delivery_provenance
if isinstance(getattr(job, "delivery_provenance", None), dict)
else {}
)
invitation = provenance.get("calendar_invitation")
request = invitation.get("request") if isinstance(invitation, dict) else None
correlation_id = (
str(request.get("correlation_id") or "").strip()
if isinstance(request, dict)
else ""
)
if correlation_id:
correlations_by_job[job.id] = correlation_id
if not correlations_by_job:
return {}
integration = calendar_integration()
if not integration.available:
return {}
try:
refs = integration.get_invitations(
session,
tenant_id=jobs[0].tenant_id,
correlation_ids=tuple(correlations_by_job.values()),
)
except Exception: # Calendar reporting must not hide Campaign evidence.
return {}
return {
job_id: _calendar_invitation_payload(refs[correlation_id])
for job_id, correlation_id in correlations_by_job.items()
if correlation_id in refs
}
def _job_attempts_payload(
send_attempts: list[SendAttempt],
imap_attempts: list[ImapAppendAttempt],
@@ -766,8 +874,16 @@ def _campaign_jobs_page_response(
)
if changed_job_ids is not None:
jobs = [job for job in jobs if job.id in changed_job_ids]
calendar_invitations = _calendar_invitations_for_jobs(session, jobs)
return CampaignJobsResponse(
jobs=[_job_summary_payload(job, reviewed_keys=reviewed_keys) for job in jobs],
jobs=[
_job_summary_payload(
job,
reviewed_keys=reviewed_keys,
calendar_invitation=calendar_invitations.get(job.id),
)
for job in jobs
],
page=page,
page_size=page_size,
total=total,
+227
View File
@@ -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()
+22
View File
@@ -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)} />