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,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))