feat(calendar_sync): read a user's calendar over the Google and Graph APIs, not just an ICS feed - #1636
Open
DmitriyG228 wants to merge 3 commits into
Open
feat(calendar_sync): read a user's calendar over the Google and Graph APIs, not just an ICS feed#1636DmitriyG228 wants to merge 3 commits into
DmitriyG228 wants to merge 3 commits into
Conversation
…ents as the ICS feed
The ICS feed is one way to learn a user's calendar, not the concept. sync_user already owns every
hard part — one row per UID, adoption by link, occurrence disposition, retirement — and consumes a
plain dict, so a calendar API is a new READER, not a new pipeline.
parse_ics(text) -> {events, cancelled_uids}
events_from_google(items) -> {events, cancelled_uids} (new)
events_from_microsoft(items)-> {events, cancelled_uids} (new)
Both are pure: no network, no tokens, no clock of their own. Recurrence expansion is the
provider's job (singleEvents=true / calendarView), after which the same load-bearing rule applies
— group by the series-stable id, keep the earliest occurrence in the window.
Keyed on iCalUID / iCalUId, deliberately: that is the value the feed carried, so a calendar
reconnected from ICS to OAuth ADOPTS its existing rows. Executed, not asserted —
test_a_calendar_reconnected_over_oauth_adopts_its_own_ics_rows asserts created == 0. Keying on the
per-occurrence event id would have re-imported every user's whole calendar as duplicates.
Two deliberate divergences from the ICS reader, both closing known residuals:
- bounded per-event snapshot instead of copying arbitrary provider properties (#1213 item 3)
- a naive provider timestamp resolves against its stated zone, else UTC — never the server's
local zone (#1316, backlog R-B10)
Attendees fold onto the ICS PARTSTAT vocabulary so nothing downstream learns two dialects.
No wiring yet: nothing calls these, no OAuth flow, no token storage, no route. This is the seam
only. [meeting-api 1450 passed / 5 skipped; 28 new]
README + __init__ describe the reader/pipeline split and what each API reader's caller must send (singleEvents+showDeleted for Google, Prefer: outlook.timezone="UTC" for Graph).
DmitriyG228
marked this pull request as ready for review
September 6, 2026 19:11
🃏 Merge card — #1636
Not mergeable yet — every row above must be accepted before merge (choke point 1). Fill in what's ❌ above, then this clears automatically. How a PR reaches merge: the merge bar. |
Member
Author
|
|
…refresh the token The I/O half of the API readers. fetch_ics dereferences a user-supplied URL and must ride the SSRF-pinned transport; these talk to two fixed hosts, so the risk inverts — what must never happen is stored user input escaping into the URL. Calendar ids are path-escaped against a constant base, redirects are off so an Authorization header cannot be moved to another host, and Graph's @odata.nextLink is host-checked before it is followed with a token attached. Error contract copied from fetch_ics: (value, human_reason), never raises. One user's dead network or revoked grant must not stall every other user's calendar in the same sweep, and the reason is what the calendar panel shows, so it says 'reconnect the calendar' rather than '401'. An invalid_grant refresh is terminal, not transient — revoked, password changed, or expired through disuse. Retrying it on a loop hammers the identity provider with a credential that will never work again, so it surfaces as reconnect. Scopes are read-only and narrow (calendar.events.readonly + calendar.calendarlist.readonly): a wider scope is a bigger consent prompt, a slower Google review, and more to lose if a token leaks. We never write to a calendar. A test pins that. Pagination is exhaustive but bounded at 20 pages — past 5000 events in a 14-day window it is not a person's calendar any more. Tokens are arguments, never state: nothing here reads a database or a secret store, which keeps it offline-testable and keeps decrypt-then-use in one auditable place upstream. [meeting-api 1467 passed / 5 skipped; 17 new]
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Draft. The seam only — nothing calls these yet. Opened as the findable artifact for a pushed
branch rather than as a review request; mark ready on the founder's word.
COMMITMENTS IN THIS PR
None. No published terms, no dates, no rights, no spend, no external promise. Additive code
behind no route and no flag: two new pure functions, their tests, and the module's own docs.
The commitments in the wider calendar-OAuth effort (the privacy-policy amendment, entering Google's
verification programme) live in
~/dev/biz/drafts/2026-09-06-calendar-oauth-review-clock.mdand aregated separately — none of them is decided here.
Why
Connecting a calendar today means pasting a secret ICS address. Google Workspace hides that field
under the default admin policy and Outlook requires publishing a calendar, so the setup step for the
buyer we care about is "file a ticket with IT". Six open issues also exist purely because we
dereference a user-supplied URL and parse RFC 5545 ourselves (#1182, #1231, #1232, #1316, #991,
#1249), and a polled feed has a 5-minute latency floor with no push.
What this is
calendar_syncwas already split so the pipeline is reader-agnostic:sync_userowns one row perUID, adoption by link, occurrence disposition and retirement, and it consumes a plain dict. So a
calendar API is a new reader, not a new pipeline.
Pure — no network, no tokens, no clock of their own. Recurrence expansion is the provider's job
(
singleEvents=true,/calendarView); we then apply the same ruleparse_icsapplies: group by theseries-stable id, keep the earliest occurrence in the window, because two scheduled rows on one
native id violate
uq_meeting_active_user_platform_native.The decision worth reviewing
Keyed on
iCalUID/iCalUId, not on the per-occurrenceevent['id']. That is the same valuethe ICS feed carries, so a calendar reconnected from ICS to OAuth adopts its existing rows.
Keying on the instance id would have re-imported every existing user's whole calendar as duplicates
on the day they switch. Executed rather than asserted —
test_a_calendar_reconnected_over_oauth_adopts_its_own_ics_rowsrunssync_userover a feed andthen over the API payload for the same meeting and asserts
created == 0.Two deliberate divergences from the ICS reader
Attendees fold onto the ICS PARTSTAT vocabulary (
accepted/declined/tentative/needs-action) so nothing downstream has to learn two dialects; rooms and equipment are dropped onboth sides, matching
service._attendees.Tests
tests/test_calendar_providers.py, 28 cases. The parity ones are the point: same PlannedEvent keysas
parse_ics, same attendee vocabulary, same one-row-per-uid rule, same "a link-less event stillimports" rule, and three that drive the real
sync_userover the in-memory store.Run locally against the repo venv, no containers.
Not in this PR
No OAuth flow, no token storage, no route, no config-record change, no push subscriptions, no UI.
Nothing imports these functions yet. Token storage in particular has a hard prerequisite: #876 —
per-user secrets sit plaintext in
users.dataJSONB, and a leaked refresh token is a differentcategory of problem from a leaked ICS URL. Envelope encryption lands before or with the connect flow.
Second commit: the I/O half
provider_io.py— fetch a window of events, refresh a token.fetch_icsdereferences auser-supplied URL and must ride the SSRF-pinned transport; these talk to two fixed hosts, so the
risk inverts and what matters is that stored user input never escapes into the URL:
../../tokeninfo?x=)Authorizationheader cannot be moved to another host@odata.nextLinkis host-checked before it is followed with a token attachedSame error contract as
fetch_ics—(value, human_reason), never raises, because one user's deadnetwork or revoked grant must not stall every other user's calendar in the same sweep. The reason is
what the calendar panel shows, so it reads "reconnect the calendar", not "401".
An
invalid_grantrefresh is treated as terminal, not transient. Retrying a dead grant on a loophammers the identity provider with a credential that will never work again.
Scopes are
calendar.events.readonly+calendar.calendarlist.readonlyand a test pins that theystay read-only. We never write to a calendar, and a wider scope is a bigger consent prompt, a slower
Google review, and more to lose if a token leaks.
Pagination is exhaustive but bounded at 20 pages: past ~5000 events in a 14-day window this is not a
person's calendar any more.
17 further tests, offline through a stub client. meeting-api: 1467 passed, 5 skipped.
Note on
value-fsmIt failed once on this branch (
test_mock_silence_left_alone→POST /bots500), then passed onre-run against the identical head. Not caused by this change:
meeting_api.calendar_syncandmeeting_api.__main__both import cleanly with the new eager import, and the suite is green locally.Flagging it because a gate that flakes on a mock scenario is worth someone's attention on its own.
Collision check
fix/rrule-expand-in-event-timezone) touchesservice.py+test_calendar_sync.py— nooverlap with these files. Its timezone reasoning is adjacent to
_iso_to_utchere; if it changeshow a naive time is read, these readers should follow.
1182-ics-size-limit) also edits this module'sREADME.md, at thefetch_icsbullet(old line ~21). This PR edits the header and adds the reader table above it. Different regions;
expected to merge cleanly.
Contribution rights
under Apache-2.0, and it is not owned or controlled by an employer, client, or other entity.
may control this contribution. I am requesting Vexa's private corporate-authorization process.
Every commit must also carry the contributor's own DCO
Signed-off-byline. Selecting theindependent path means no individual CLA is required.