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
@@ -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"])