Calendar Module
The calendar module manages calendars and events stored in the database or fetched from external ICS feeds.
It is built around two domain objects - CalCalendar and CalEvent - that flow through sources, repositories, RRULE expansion, and serializers without any format leaking across layers.
Architecture overview
Interface (API) Module Sources / Engines Persistence
-------------- ------ ----------------- -----------
InterfaceApi ModuleCalendar CalendarSources RepositoryEvent
| | | RepositoryCalendar
| CalEvent | |
+----------------->| CalEvent |
|--------------------->|
| +-- CalendarSourceDb --> SQL (sogo6_calendar_events)
| +-- CalendarSourceIcsMirror -> SQL (read-only mirror)
|
+-- RecurrenceScopeProcessor (split / occurrence / standard)
+-- RruleEngine (RRULE expansion, EXDATE, overrides)
+-- FreeBusyEngine (free/busy computation)
+-- ReminderEngine (active reminder computation)
+-- SyncEngine (ICS mirror synchronization)
+-- CalendarAclEngine (permission resolution + enforcement)
Responsibilities:
-
InterfaceApi - Dict-to-CalEvent conversion (via Deserializer), scope dispatch, response formatting. No business logic.
-
ModuleCalendar - Orchestration: event CRUD, attendance, iMIP, attendee propagation. Delegates recurrence scope to the processor.
-
RecurrenceScopeProcessor - Pure construction logic for recurrence-scoped modifications (static methods). Returns a
ScopeResultcarrying the resulting CalEvent and the list of touched events with their action (INSERT/UPDATE/DELETE). -
CalendarSources - Factory for CalendarSource instances, multi-calendar merge. Single entry point for all attendee propagation via
propagate()which replicates touched events and syncs the attendee list. -
CalendarSourceDb - Writable source backed by the database. Handles insert, update, split, detached occurrence EXDATE. No cross-calendar propagation - that belongs to CalendarSources.
-
CalendarSourceIcsMirror - Read-only DB mirror for external ICS subscriptions. Extends CalendarSourceDb with
is_writable() → False. Events are populated by SyncEngine, not by direct API writes. -
SyncEngine - Fetches a remote ICS feed via IcsFetcher, diffs by (UID, recurrence_id) against local DB, inserts/updates/deletes accordingly. Updates sync_config status on the calendar.
-
RruleEngine - RRULE expansion into occurrences, EXDATE suppression, override replacement. Overrides take priority over EXDATE.
-
ReminderEngine - Determines which reminders are currently active based on trigger_at, event end, and optional lookahead. Expands recurring events.
-
RepositoryEvent / RepositoryCalendar / RepositoryReminder - SQL-only persistence. No business logic.
-
Serializers / Deserializers - Format conversion (Dict, ICS).
deserialize_with_update()merges a dict update onto an existing CalEvent.
Domain model
CalEvent is the single canonical representation of a calendar component (VEVENT, VTODO or VJOURNAL).
It is a plain Python dataclass. The component_type field discriminates between component kinds stored in the same table.
Fields are either relational (dedicated columns for filtering: date_start, date_end, component_type, show_as, is_deleted, recurrence_id) or blob (everything else, stored as JSON in cal_event). Unpersisted fields (dates_with_tz) are computed at serialization time and excluded from the blob via CalEvent.UNPERSISTED_FIELDS.
date_end holds the component end instant: DTEND for a VEVENT, DUE for a VTODO. It is nullable: a VEVENT always has a DTEND (enforced by CalEvent.validate), but a VTODO may have no DUE, in which case date_end is NULL and the task API surfaces date_due: null. A task with no due date has no end, so it is treated as open-ended (zero duration on expansion, never filtered out as "finished").
CalCalendar maps to the sogo6_calendar_calendars table. ctag is incremented on every event mutation by CalendarSourceDb._bump_ctag() (CalDAV change detection via the CS:getctag extension).
All datetimes are stored and processed in UTC. The original TZID is preserved in CalEvent.timezone for display only.
Calendar new-event defaults and free/busy participation
A calendar carries four per-calendar settings, exposed on create/update (POST/PATCH /calendars) and in the calendar response:
-
include_in_freebusy(bool, defaulttrue) - a relational column (likeis_default), not a blob. Whenfalse, the calendar’s events are excluded from the owner’s free/busy:CalendarSources.get_freebusy_eventsskips the calendar entirely so none of its events ever contribute a busy slot. -
default_event_duration_min(int, nullable) - when a new timed event arrives without adate_end, the value derives one (date_end = date_start + duration). Skipped for all-day events and tasks (a VTODO’s DUE stays optional), and never overrides an end the caller already provided. -
default_alarm_duration_min(int, nullable) - when an alarm is added without an explicit offset, this becomes itsminutes_before. It is not auto-created: no alarm is added to an event that has none. -
default_type(RFC 5545 CLASS, nullable) - the visibility applied to a new event that does not specify one.
The three new-event defaults are read-simple UI values, never filtered or sorted, so they are grouped in a single nullable preferences JSON column (same convention as sync_config) rather than three columns; RepositoryCalendar packs/unpacks them. include_in_freebusy stays relational because it gates an aggregation.
Defaults split into two layers by their lifecycle:
-
Creation-time defaults (
default_type,default_event_duration_min) are applied byCalEvent.apply_defaults(default_visibility, default_duration_min), called fromModuleCalendar.create_event/create_taskonce the parent calendar is resolved. They only make sense on a brand-new event (you do not re-pick a visibility or re-derive an end on every edit) and fall back to the global defaults (public; no derived end) when the calendar value isNone. For this to work the deserializer leavesvisibilityatUNDEFINEDwhen the caller omits it (an eagerPUBLICwould mask the calendar default). -
Persistence-time preparation (
default_alarm_duration_minresolution and the all-day DTEND invariant) lives at the write chokepoint:CalendarSourceDb._prepare_for_persistence, run at the top of everyinsert_event/update_event, callsCalEvent.normalize_all_day()andCalEvent.resolve_reminder_offsets(calendar.default_alarm_duration_min). Placing it there covers create, update, tasks, attendee copies and recurrence splits uniformly, so no caller has to remember it. An unspecified alarm offset travels asCalReminder.minutes_before = Nonefrom the deserializer and is resolved here (global fallbackDEFAULT_REMINDER_MINUTES);CalReminder.require_minutes_beforeraises if aNoneever reached the reminder row, as a last-resort guard.
Calendar sources
CalendarSource is an abstract base class with a fixed processing pipeline:
-
Resolve and normalize date bounds to UTC
-
_fetch_events()- subclass-specific query -
_expand_recurring()- RRULE expansion -
_filter()- date range + optional full-text search -
Sort and stamp
calendar_key/calendar_timezone
Two implementations: CalendarSourceDb (writable, DB-backed) and CalendarSourceIcsMirror (read-only DB mirror for external ICS subscriptions; extends CalendarSourceDb and forces is_writable() to False).
CalendarSources is the factory and ownership enforcer. All reads are scoped to a user_uid.
Recurrence
RruleEngine
RruleEngine.expand() expands a recurring master into occurrences within a date window. Each generated occurrence carries the master’s recurrence_rule so clients can display recurrence info.
Override priority: when both an EXDATE and a detached override (RECURRENCE-ID) exist for the same slot, the override wins (RFC 5545 §3.8.4.4).
Recurrence exceptions and occurrences
A recurring event can be modified at three granularity levels:
-
Single occurrence - A detached row with
recurrence_idreplaces one generated slot. The master’s EXDATE list is updated for CalDAV compatibility, but the RRULE expander prefers the override over the EXDATE. -
This and following - The original series is truncated (
COUNTconverted toUNTIL). A new independent master is created from the split point withuid_parent_splitlinking back. -
All events - Standard update applied to the master. Detached occurrences are realigned when the master time changes.
RecurrenceScopeProcessor
Static processor. process() dispatches to split_occurrence, split_series, or standard update. Returns a ScopeResult carrying the resulting CalEvent and a touched list of (CalEvent, EventAction) pairs - all events that were modified/created/deleted on the organizer’s calendar. The caller passes the ScopeResult to CalendarSources.propagate() which blindly replicates the touched list to each attendee (INSERT/UPDATE/DELETE) and syncs the attendee list.
API examples
All examples assume a weekly recurring event with key evt-key.
Modify all events:
PATCH /events/evt-key
{"title": "New Title"}
Modify this event only:
PATCH /events/evt-key
{"title": "Special", "recurrence_id": "2026-06-08T07:00:00Z"}
Move a single occurrence (drag & drop):
PATCH /events/evt-key
{"date_start": "2026-06-09T10:00:00Z", "date_end": "2026-06-09T11:00:00Z", "recurrence_id": "2026-06-08T07:00:00Z"}
This and following events:
PATCH /events/evt-key
{"title": "New Series", "recurrence_id": "2026-06-15T07:00:00Z", "recurrence_range": "THISANDFUTURE"}
Delete this and following (truncate only):
PATCH /events/evt-key
{"recurrence_id": "2026-06-15T07:00:00Z", "recurrence_range": "THISANDFUTURE"}
Cancel a single occurrence (EXDATE):
PATCH /events/evt-key
{"recurrence_exceptions": ["2026-06-08T07:00:00Z"]}
Delete all events:
DELETE /events/evt-key
Decline a single occurrence as attendee:
POST /events/evt-key/attendance
{"status": "declined", "recurrence_id": "2026-06-08T07:00:00Z"}
Reminders
ReminderEngine determines which reminders are currently active. A reminder is active from trigger_at (= date_start - minutes_before) until date_end + lookahead. For recurring events, occurrences are expanded via RruleEngine and each is checked independently.
Reminders are materialized in sogo6_calendar_reminders with a pre-computed trigger_at for efficient SQL queries. CalendarSourceDb maintains the table automatically on insert, update and delete. RepositoryReminder.find_pending() uses a JOIN (reminders → events → calendars) to filter by user, trigger window and soft-delete in a single query.
GET /reminders?method=popup&lookahead=30 returns active reminders with event context (title, location, dates, dates_with_tz).
External calendars
External ICS subscriptions are stored as CalCalendar rows with source_type=ics and sync metadata in sync_config JSON (url, username, password, etag, last_sync, sync_interval_minutes, sync_status). Events are mirrored into sogo6_calendar_events by SyncEngine via CalendarSourceIcsMirror. Write restrictions on ICS calendars are enforced by CalendarAclEngine, not by the source layer.
Sync runs asynchronously as an Agent job (see Agent) and comes in two modes that both converge on SyncEngine.sync:
-
Manual -
POST /external-calendars/{key}/syncenqueues acalendar.sync.external.manualjob (ACL / source-type checked synchronously) and returns202with ajob_id; the caller pollsGET /jobs/{job_id}for the counters, orGET /external-calendars/{key}/syncfor the durablesync_status. -
Auto -
calendar.sync.external.autois a periodic beat job (cron = "/5 * * * *", system-wide, modelled on the large-store cleanup) that sweeps every external ICS calendar and syncs the ones that are *due inline, one failure never aborting the batch. A calendar is due when it has never synced, or whennow - last_sync >= sync_interval_minutes(a per-calendar sliding window stored insync_config, default 60, min 5) and it is not alreadyRUNNING. The 5-minute cadence is the resolution floor.ModuleCalendar.sync_all_due_external(system, no user scope) drives the sweep;RepositoryCalendar.find_all_externalenumerates the ICS calendars across all users.
In both modes the engine fetches the ICS feed via IcsFetcher, diffs by (UID, recurrence_id), and applies inserts/updates/deletes; SyncEngine holds a per-calendar Redis lock so the same calendar is never synced twice at once. IcsFetcher protects against SSRF by validating that the resolved hostname is a public IP and pinning the TCP connection to that IP for the lifetime of the request (anti DNS-rebinding); redirects are re-validated against the same rules. Sync status (sync_status, last_sync, sync_error) is tracked in sync_config and queryable via GET /external-calendars/{key}/sync.
Export and import
Both export and import run asynchronously as Agent jobs (see Agent): the endpoint enqueues a job and returns 202 with a job_id, the caller polls GET /jobs/{job_id} until SUCCESS, then reads the outcome. This keeps the request non-blocking for large calendars and bounds concurrency (one export and one import in flight per user via max_concurrent).
Export
GET /calendars/{key}/export enqueues a JobExportIcs job and returns 202 {job_id}. Recurring masters keep their RRULE intact - the expansion happens on the recipient side. Query parameters (forwarded to the job):
-
start_date_time,end_date_time- same semantics as the events list, restrict the export window.
The serialised ICS is offloaded to the agent’s ClientStorage (the shared file store, see Agent); the job result carries only the storage reference (sogo:file:<key>) plus {filename, size_bytes}. The caller fetches the bytes from GET /jobs/{job_id}/result (text/calendar); ?download=true adds Content-Disposition: attachment so browsers download instead of inlining.
Worker-side (JobExportIcs → InterfaceAgentCalendar → ModuleCalendar), the module fetches both events and tasks without expansion via CalendarSource.get_all_events(…, expand=False) and get_all_tasks(…, expand=False), attaches them to the CalCalendar (its transient events field), then CalCalendarSerializerIcal.serialize(calendar) builds the VCALENDAR: it owns the calendar-level header (PRODID, VERSION, CALSCALE, the X-WR-CALNAME/X-WR-CALDESC/X-WR-TIMEZONE descriptors) and delegates the body - one VEVENT/VTODO per event - to CalEventsSerializerIcal. expand=False keeps recurring masters with their RRULE (no flat occurrences) and skips the Python date filter since the SQL fetch already bounds the range.
Import
POST /calendars/{key}/import accepts a multipart/form-data upload with a single file part. The view exposes accepted_content_types = {"multipart/form-data"} so the global check_content_type middleware skips its default JSON rule (declared per-route via the accepted_content_types attribute). The total request size is capped at the WSGI layer (MAX_CONTENT_LENGTH); the module enforces the per-import limit (MAX_IMPORT_ICS_BYTES).
The endpoint enqueues a JobImportIcs job and returns 202 {job_id}. The import counters {inserted, updated, deleted, total, skipped} are returned inline in the job result, readable via GET /jobs/{job_id}.
The pipeline is ApiCalendarImport (decodes the upload to text - utf-8, latin-1 fallback - the one place the charset is guessed) → InterfaceApiCalendarCalendar.import_calendar → ModuleCalendar.import_calendar (size/ACL check, offloads the text to the agent’s ClientStorage via save_text, enqueues JobRequestImportIcs with the blob reference) → worker JobImportIcs (reads it back with load_text, then deletes the blob) → ModuleCalendar.apply_import → SyncEngine.apply_ics(…, delete_missing=False, rewrite_owner_email=…, default_timezone=…). apply_ics deserialises the payload through CalendarDeserializerIcal, which reads the calendar-level header and delegates the body to CalendarEventsDeserializerIcal; only the parsed events are applied to the target calendar (the imported VCALENDAR’s own name/timezone are not used - the import writes into an existing calendar). Key behaviours:
-
delete_missing=False: the import is additive - events absent from the upload are kept (unlike the URL sync which mirrors). -
rewrite_owner_email: whenIMPORT_REWRITES_OWNERSHIP=True(default, inCalendarConst),apply_icsworks on the deserialized models - never on the raw iCal text - settingevent.organizerto the importer and resettingevent.sequenceto 0 (RFC 5546 §2.1.4: SEQUENCE belongs to the organizer). Attendees are preserved so the historical guest list is kept; iMIP only fires on subsequent explicit edits. -
default_timezone: floating datetimes (noTZID, noZ) are anchored to the destination calendar’s timezone (CalCalendar.timezone), which itself defaults to the user’sSOGO_U_TIMEZONEat calendar creation. Anchoring is done byDateTimeUtils.anchor_to_utc. -
Orphan overrides -
RECURRENCE-IDentries whose master is neither in the payload nor already in the calendar - are skipped with aWARNINGlog and counted inCalSyncResult.skipped(RFC 5545 §3.8.4.4 considers them invalid).
Public subscription URL
A calendar owner can expose a read-only public ICS feed (the same model as Google Calendar’s "secret address"). Access is a capability URL: the secret token embedded in the URL is the credential - there is no separate login.
Enabling and disabling
-
POST /calendars/{key}/subscription- generates a unique random token, stores it in theshare_tokencolumn (a single column reused for this purpose), and returns{share_token, public_url}. Re-enabling rotates the token. RequiresMODIFYon the calendar. Gated by the domain settingSOGO_D_CALENDAR_PUBLIC_LINK_ENABLED(defaultTrue): when the domain disables it,ModuleCalendar.enable_subscriptionraisesERROR_CALENDAR_PUBLIC_LINK_DISABLED(403 - the caller is authenticated, no information leaks). The typed domain settings are passed as a method argument, like theUser: they derive from the acting user, so they travel with the call rather than being stored on the module instance. -
DELETE /calendars/{key}/subscription- clears the token, immediately invalidating the public URL. Always allowed, regardless of the domain setting (a user must be able to revoke an existing link even after the feature is disabled). -
GET /calendars/{key}andGET /calendarsincludepublic_url(the absolute URL, ornullwhen no subscription is active).
The token is produced directly with get_unique_token(SHARE_TOKEN_LENGTH) (64 alphanumeric chars, ~381 bits). At that entropy a collision is statistically impossible, so there is no pre-check query: uniqueness is guaranteed by the UNIQUE constraint on the share_token column. Lookups use RepositoryCalendar.find_by_share_token, a global query with no user scope - the token alone identifies the calendar.
The public_url is built by InterfaceApiCalendarCalendar._public_url, which delegates to the transversal helper app.utils.api.external_url.build_external_url: it prefixes the route path with SOGO_P_PUBLIC_BASE_URL when configured (required behind a reverse proxy), otherwise falls back to Flask’s external URL.
Public access endpoint
GET /public/calendars/{token} declares public_access = True on its MethodView; the auth middleware reads that attribute by introspection (the same mechanism as accepted_content_types) and lets the request through without a bearer token, so app/init.py carries no per-route calendar knowledge. Anonymous requests fall back to default_domain_settings. The endpoint returns the full calendar serialised as text/calendar; charset=utf-8 (synchronous, the same serialisation path as export) - including a REFRESH-INTERVAL / X-PUBLISHED-TTL (PUBLIC_SUBSCRIPTION_REFRESH, default PT12H) that advertises the resync period to subscription clients (Apple, Google, Outlook). An unknown or revoked token is a plain 404 - no data leaks and the token is never logged in full (only token[:8] for correlation), since the token is a secret credential.
The fetch is also gated by the owner’s domain: when SOGO_D_CALENDAR_PUBLIC_LINK_ENABLED is disabled for the calendar owner’s domain, existing URLs go dead with the same 404 as an unknown token (on purpose - a disabled domain must not reveal that a token exists). The token is kept in the database, so re-enabling the setting revives the URLs unchanged. Since the caller is anonymous, the relevant domain is discovered from the token, with the interface orchestrating the two modules sequentially: ModuleCalendar.get_calendar_by_share_token resolves the owner, _calendar_settings_by_uid loads that domain’s settings through ModuleAdminConfig, and export_by_share_token receives them - the calendar module itself never knows the admin module.
The pipeline goes ApiCalendarPublicSubscription → InterfaceApiCalendarCalendar.export_public_calendar → ModuleCalendar.get_calendar_by_share_token → ModuleCalendar.export_by_share_token.
Access control (ACL)
User vs CalendarUser
Module methods use two distinct parameter types:
-
User- for calendar-level operations (list, create, update, delete calendars, sync). The user operates on their own calendars. -
CalendarUser- for event-level operations (CRUD events, tasks, reminders, attendance). Carries both the acting user (user) and the calendar owner (owner). For personal calendars,user == owner. For shared calendars, they differ.
The interface builds CalendarUser from the authenticated user. Until the central ACL module lands, the owner is always the acting user (user == owner); shared-calendar dispatch will plug in here.
CalendarAclEngine
Centralizes all permission logic. Three responsibilities:
-
get_permissions(calendar, calendar_user)→CalendarPermissions- resolves what the user can do on this calendar. Owner gets full access. ICS calendars get read-only (VIEW_ALL, no create/delete). Non-owner permissions are stubbed (denied) until the ACL module is implemented. -
check_permission(permissions, action, event=None, calendar_user=None)- raisesERROR_CALENDAR_ACCESS_DENIEDif the action is not allowed. Event-level callers (update event/task) pass the event and the acting user so the conditionalMODIFY_IF_ORGlevel can be resolved; calendar-level MODIFY checks (e.g. managing the public subscription) pass neither and are never satisfied byMODIFY_IF_ORGalone. -
sanitize_events(events, permissions)- masks event fields based on visibility class (public/confidential/private) and permission level. Events with NONE level are excluded. VIEW_DATETIME level replaces title with "Busy" and hides details.
Permission model
CalendarPermissions has one CalendarShareLevel per visibility class (public, confidential, private) plus can_create and can_delete flags. Share levels are ordered: NONE < VIEW_DATETIME < VIEW_ALL < RESPOND < MODIFY_IF_ORG < MODIFY.
MODIFY_IF_ORG is the conditional level between RESPOND and MODIFY: the user gets MODIFY only on events they ORGANIZE, and behaves as RESPOND on everything else. Levels serialise by name (modify_if_org in the API), so the numbering is internal.
Each CalCalendar carries a transient permissions field populated at read time - the API response includes the user’s permissions so the UI knows which actions to enable.
Repositories
Repositories are the only classes that issue SQL. All queries use structured Condition objects - no string interpolation. find_by_calendar applies different SQL conditions for recurring vs non-recurring events.
Serialization
All serializers extend Serializer[TIn, TOut], all deserializers extend Deserializer[TIn, TOut]. The icalendar library is used only in *Ical.py files. deserialize_with_update(origin, update) merges a dict onto an existing CalEvent for partial updates.
Module - ModuleCalendar
ModuleCalendar is the single entry point for business logic. It delegates:
-
Recurrence scope to
RecurrenceScopeProcessor -
Persistence to
CalendarSourceDbviaCalendarSourcemethods -
Attendee propagation to
CalendarSources -
Free/busy computation to
FreeBusyEngine -
Active reminder computation to
ReminderEngine -
Permission resolution and enforcement to
CalendarAclEngine
The organizer is always set by the caller (interface) as a CalOrganizer object. The module never constructs organizer identity.
get_all_events and get_all_tasks enforce a maximum date range (configurable via CalendarConst).
iMIP
app/module/calendar/imip/
Three iTIP methods (RFC 5546): REQUEST (invite), REPLY (accept/decline), CANCEL (delete). ImipProcessor handles inbound messages. ImipBuilder produces outbound ImipMessage objects. Actual email dispatch is deferred to the Celery agent.
For local attendees, CalendarSources.propagate() mirrors the touched events from a ScopeResult to each attendee’s writable calendar immediately, bypassing the email roundtrip.