docs/plans/2026-07-14-microsoft-365-calendar-provider.md
Status: proposed, revised after repository audit and adversarial Codex review · 2026-07-14
This is feasible, but it is not a mechanical rename of the Google Calendar plugin. Microsoft Graph event mapping is the easy half. The harder half is operating a multitenant Microsoft Entra application reliably in university tenants and closing several generic plugin-host gaps around OAuth, polling, throttling, all-day dates, and device-local credentials.
The smallest responsible product is a bundled, read-only, Electron-only Microsoft 365 Calendar provider for work/school accounts. It should let a user authenticate without an iCal URL, select their own calendars, see events in Schedule/Planner, manually turn an event into a task, and open the original event in Outlook.
Do not begin the production implementation until the Phase 0 tenant gate succeeds. Code cannot bypass a university's consent, Conditional Access, or enterprise-app policy.
| Outcome | Estimate | Confidence |
|---|---|---|
| Disposable Graph/tenant feasibility spike | 1–2 engineering days | Medium; tenant policy is external |
| Production Electron MVP, including host hardening and tests | 17–26 additional focused engineering days | Medium |
| Total engineering effort | About 4–6 weeks for one engineer | Medium |
| Microsoft publisher/admin approval | Not included | Unbounded external elapsed time |
A demo that only logs in and lists events could be built in a few days. Calling that production-ready would hide the main risks found in review: unsupported mobile flows currently start OAuth, all calendar providers can be polled at the fastest provider's cadence, linked tasks cause per-task Graph requests, refresh-token persistence is not transactional, and synced calendar IDs can meet credentials for a different local account.
Some universities require Outlook/Microsoft 365 but disable calendar publication, so the existing URL-based iCal integration cannot be used. The requested workflow is personal planning, not team calendar management:
This fits Super Productivity's deep-work scope as an optional integration. It must be quiet by default, read-only, least-privilege, and safe when offline or disconnected.
The repository already has most of the structural pieces:
packages/plugin-dev/google-calendar-provider/ demonstrates a bundled OAuth
agenda provider, dynamic calendar selection, recurrence expansion through a remote
API, event-to-task mapping, and provider-local tests.src/app/plugins/oauth/ supplies authorization-code + PKCE flows, Electron
loopback callbacks, token refresh, and local IndexedDB token storage.packages/plugin-api/src/issue-provider-types.ts already represents agenda events
with start, duration, all-day, due-time, and source URL fields.src/app/features/calendar-integration/ already combines plugin events with iCal
events and exposes task creation and source-link actions.packages/plugin-dev/scripts/build-all.js, src/app/plugins/plugin.service.ts, and
electron/bundled-plugin-ids.test.cjs define the bundled-plugin build and reserved-ID
path.The provider should extend these building blocks. It should not introduce a second calendar framework, a Microsoft SDK, a backend token broker, or a new root dependency.
The initial outline was too optimistic in the following ways. These are requirements, not optional polish:
getById request per
task on every issue-poll cycle./me/calendars can expose locally represented shared/delegated calendars. The MVP
must filter to calendars owned by the signed-in mailbox instead of merely hiding a
label in the UI.Retry-After. A small typed error extension
is preferable to blind sleeps or exposing every response header as a permanent
plugin API.dueDay date string. Reconstructing it from a
UTC millisecond timestamp can shift it by a day when mailbox and device time zones
differ.i18n.languages: ["en"], i18n/en.json, build copying,
and PluginAPI.translate; copying the current Google scaffold literally would miss
those requirements.Calendars.ReadBasic plus offline_access.The exclusions should appear in the setup copy and documentation, not only in code comments.
Microsoft Entra authorization (system browser + PKCE)
|
v
device-local OAuth token store (one account per plugin, never synced)
|
v
Microsoft provider -> validated Graph client -> bounded in-memory event cache
| |
| +-> local title search / getById reuse
v
existing issue-provider agenda contract
|
v
calendar integration cache -> Schedule/Planner -> manual task snapshot
Provider configuration, including selected calendar IDs, remains part of synced issue provider state. Tokens and the provider's event cache remain device-local. On each device, the selected IDs must be reconciled against the calendars returned for that device's connected account before Graph event calls begin.
These values make “bounded” testable and avoid adding settings before a real workflow requires them:
| Concern | MVP rule |
|---|---|
| Tenant endpoint | organizations authorization/token endpoints |
| Delegated scopes | offline_access https://graph.microsoft.com/Calendars.ReadBasic |
| Calendar ownership | Compare each calendar owner to the default calendar's owner; if ownership is missing/ambiguous, expose only the default calendar and fail closed for the rest |
| Selected calendars | 1 required, 10 maximum |
| Event window | Local start-of-day −7 days through local start-of-day +28 days, sent as explicit-offset instants |
| Graph page size | $top=100 |
| Pagination | At most 5 pages per calendar and 2,000 mapped events total |
| Calendar request concurrency | 2 |
| Request timeout | 30 seconds |
| Agenda cadence | 5 minutes per Microsoft provider; manual refresh may bypass the due time |
| Overlap | One in-flight fetch per account/config cache key; later automatic ticks reuse it |
| Search | Case-insensitive title search, 50 results maximum, over the same bounded cache |
Retry without Retry-After | At most 2 retries using approximately 1 s and 2 s exponential delay plus jitter |
Retry with Retry-After | Do not retry before the supplied time; retry once only when the wait is at most 30 s, otherwise stop and retain stale data |
| Timed event identity | Composite, reversible encoding of case-sensitive calendar ID + immutable event ID |
| All-day identity/date | Preserve YYYY-MM-DD start and exclusive end dates separately from numeric schedule instants |
| Content limits | Validate response shapes; cap IDs/URLs/titles to documented local constants before mapping |
If a cap is reached, fail that refresh with a safe localized “calendar result limit reached” error and keep the last complete provider snapshot. Do not silently replace a complete cache with a truncated one.
invalid_grant, interaction_required, or a claims challenge becomes
reconnect-required and purges the affected provider's cached event metadata.calendarView calls. Ask the user to reselect
calendars for the locally connected account.This phase deliberately precedes repo implementation.
http://127.0.0.1:<fixed-high-port>/<fixed-callback-path> through the Entra
application manifest. Microsoft does not treat a literal 127.0.0.1 port as
interchangeable, so the exact URI matters.Calendars.ReadBasic; request offline_access in the OAuth flow./me/calendars, and one
calendarView call with:
Calendars.ReadBasic: calendar owner/default flags, event subject/start/end,
isAllDay, cancellation/response status, immutable ID, and webLink.Go: at least one representative university user can consent, or the university has a realistic documented admin-approval route; the app registration can be operated and published by the project; all required fields are available at read-basic scope.
No-go: representative tenants categorically block the app with no workable approval path, publisher requirements cannot be met, or required event/ownership fields demand a broader permission the project is unwilling to request. In that case, answer the user honestly; no client implementation can override the policy.
The spike should leave a short evidence note with tenant type, requested scopes, redirect used, success/failure category, and redacted screenshots/errors. Never commit tokens, tenant IDs, user addresses, authorization codes, or client secrets.
Phase 0 tenant gate
-> host OAuth safety
-> host HTTP/polling/calendar contracts
-> provider Graph slices
-> cross-tenant/manual verification
-> docs and pilot
Stop at each checkpoint if the preceding contract cannot be made reliable without a larger architectural change.
Each task is intentionally reviewable on its own. File lists are expected touch points, not permission to broaden the task.
Depends on: Phase 0 green.
Add an optional, backward-compatible supportedPlatforms field to
OAuthFlowConfig. Existing plugins that omit it keep current behavior; the Microsoft
provider declares Electron only. Enforce the field before preparing a redirect server
or opening a browser.
Likely files:
packages/plugin-api/src/types.tssrc/app/plugins/oauth/resolve-effective-oauth-config.util.tssrc/app/plugins/oauth/resolve-effective-oauth-config.util.spec.tssrc/app/plugins/oauth/plugin-oauth-bridge.service.tssrc/app/plugins/oauth/plugin-oauth-bridge.service.spec.tsAcceptance: web/native attempts fail before any OAuth side effect; Google behavior is
unchanged. Verify with targeted specs and npm run checkFile for every changed TypeScript
file. Estimate: 0.5 day.
Depends on: Task 1.
Replace the web-only availability check in the provider dialog with the new platform contract and a localized desktop-only explanation.
Likely files:
src/app/features/issue/dialog-edit-issue-provider/dialog-edit-issue-provider.component.tssrc/app/features/issue/dialog-edit-issue-provider/dialog-edit-issue-provider.component.htmlsrc/app/features/issue/dialog-edit-issue-provider/dialog-edit-issue-provider.component.spec.tssrc/assets/i18n/en.jsonAcceptance: unsupported builds show a disabled connect action and never start OAuth; Electron remains connectable. Estimate: 0.5 day.
Depends on: Task 1.
Define internal refresh outcomes for success, transient failure, and terminal reauthentication. On success, replace a rotated refresh token (or retain the old one if none is returned), persist the full new token set, and only then return the access token. Increment a per-plugin generation on connect/disconnect; a late refresh from an older generation must be discarded and must not write to memory or IndexedDB. Deduplicate concurrent refreshes within the same generation.
Expose an additive force-refresh option for the one-retry-on-401 path and emit a plugin-session-changed event after connect, disconnect, or terminal invalidation. Transient network/429/5xx errors retain credentials.
Likely files:
src/app/plugins/oauth/plugin-oauth.model.tssrc/app/plugins/oauth/plugin-oauth.service.tssrc/app/plugins/oauth/plugin-oauth.service.spec.tssrc/app/plugins/oauth/plugin-oauth-bridge.service.tssrc/app/plugins/oauth/plugin-oauth-bridge.service.spec.tsAcceptance: tests cover rotation, no rotation, persistence failure, refresh deduplication, disconnect during refresh, reconnect/account switch during refresh, restart restoration, transient failure, terminal failure, and forced refresh. No token or response body is logged. Estimate: 2–3 days.
Depends on: Phase 0's exact redirect.
At PLUGIN_OAUTH_START, extract the expected state and callback path from the validated
authorization URL. The loopback server must ignore unrelated paths and wrong-state
requests without marking the flow handled or closing. Close only after a matching
callback, timeout, explicit cancellation, or startup error. Keep the existing clear
EADDRINUSE error for the fixed port.
Likely files:
electron/plugin-oauth.tsAcceptance: wrong path/state cannot consume the real callback; correct error and code callbacks complete once; collision and timeout clean up. Estimate: 0.5–1 day.
Depends on: none after Phase 0; land before Graph retry logic.
Add an optional typed error shape containing only normalized status,
retryAfterMs, and a stable error category. Parse both seconds and HTTP-date forms of
Retry-After. Do not expose arbitrary headers and do not change successful response
shapes. Keep the addition backward-compatible for existing plugins.
Likely files:
packages/plugin-api/src/issue-provider-types.tssrc/app/plugins/issue-provider/plugin-issue-provider.model.tssrc/app/plugins/issue-provider/plugin-http.service.tssrc/app/plugins/issue-provider/plugin-http.service.spec.tsAcceptance: Electron and supported native paths produce the same safe error fields for 401/403/404/429/5xx/timeout; existing consumers still receive their expected data. Estimate: 1–1.5 days.
Depends on: none; required before enabling the provider.
The combined calendar timer may continue waking at the smallest configured interval, but it must call only providers that are due. Track last attempt/success and an in-flight promise per provider ID. A manual refresh marks providers due; an automatic tick never starts a second request for a provider already in flight.
Likely files:
src/app/features/calendar-integration/calendar-integration.service.tssrc/app/features/calendar-integration/calendar-integration.service.spec.tsAcceptance: with Google at one minute and Microsoft at five, Microsoft is called once per five minutes; slow requests never overlap; enable/disable and provider removal clear cadence state. Estimate: 1–1.5 days.
Depends on: none; additive public contract.
Add defaultAutoPoll?: boolean to the issue-provider manifest plumbing. Preserve the
current default for existing plugins; Microsoft sets it to false. This prevents hidden
agenda-view defaults from starting per-linked-task Graph polling.
Likely contract/plumbing files:
packages/plugin-api/src/issue-provider-types.tssrc/app/plugins/issue-provider/plugin-issue-provider.model.tssrc/app/plugins/issue-provider/plugin-issue-provider-registry.service.tssrc/app/plugins/issue-provider/plugin-issue-provider-registry.service.spec.tssrc/app/plugins/plugin-bridge.service.tsThen apply it in the provider setup model and cover it in the dialog spec. Acceptance:
new Microsoft configurations store isAutoPoll: false; Google and existing providers
retain current defaults. Estimate: 0.5–1 day.
Depends on: contract review checkpoint.
Add optional dueDay?: string to PluginSearchResult and
CalendarIntegrationEvent. Preserve dueDay and url when mapping plugin agenda
results. Validate dueDay as YYYY-MM-DD; the issue adapter must prefer it over
deriving a date from start milliseconds. Event opening prefers the canonical HTTPS
URL and falls back to getIssueLink.
Likely files, split into two small commits if needed:
packages/plugin-api/src/issue-provider-types.tssrc/app/features/calendar-integration/calendar-integration.model.tssrc/app/features/calendar-integration/calendar-integration.service.ts and specsrc/app/plugins/issue-provider/plugin-issue-provider-adapter.service.ts and specsrc/app/features/calendar-integration/calendar-event-actions.service.ts and specAcceptance: mailbox/device timezone differences never shift all-day task dates; canonical URL, fallback URL, malformed URL, and ordinary iCal behavior are covered. Estimate: 1–2 days.
Depends on: Tasks 3 and 8.
Consume the OAuth session-change event in calendar integration. Remove in-memory and local-storage entries belonging to provider configurations registered by that plugin on disconnect, account replacement, or terminal invalidation. Retain the last complete snapshot for transient offline/429/5xx failures.
Likely files:
src/app/features/calendar-integration/calendar-integration.service.tssrc/app/features/calendar-integration/calendar-integration.service.spec.tsAcceptance: old account titles/links are not visible after disconnect or reconnect; offline startup can still show the prior account's cache only while that same OAuth session remains valid. Estimate: 1 day.
Checkpoint A: run all targeted OAuth, plugin HTTP, calendar integration, issue adapter, provider-dialog, and Google Calendar tests. Review every additive public type before starting the Microsoft package. If these changes need a breaking plugin API, stop and write an architecture decision instead.
Depends on: Checkpoint A.
Create packages/plugin-dev/microsoft-calendar-provider/ with the Google provider's
Vitest/esbuild shape, but follow the current plugin i18n contract rather than copying
Google's hard-coded labels.
Required assets:
microsoft-calendar-provider;i18n.languages: ["en"] and i18n/en.json;PluginAPI.translate for every user-facing provider string;useAgendaView: true, five-minute agenda interval,
defaultAutoAddToBacklog: false, and defaultAutoPoll: false;supportedPlatforms: ["electron"].Likely package/tooling files: package.json, package-lock.json, tsconfig.json,
vitest.config.ts, and scripts/build.js. Keep runtime code dependency-free; scoped
build/test packages mirror the existing plugin. Estimate: 1 day.
Depends on: Task 10.
Create small typed modules for config validation, Graph response validation, composite
IDs, URL allowlisting, and date mapping. Treat every Graph response and nextLink as
untrusted. Every bearer-authenticated absolute URL must be HTTPS with hostname exactly
graph.microsoft.com; reject credentials, alternate ports, lookalike suffixes, and
redirect-derived hosts.
Mapping rules:
Prefer: IdType="ImmutableId" on every event request;webLink values.Acceptance: pure tests cover invalid shapes, oversized fields, hostile nextLink, ID
case, recurrence instances/exceptions, missing titles, timed timezone offsets, mailbox
timezone different from device timezone, travel, DST, and multi-day all-day events.
Estimate: 2 days.
Depends on: Tasks 3, 4, 10, and 11.
Configure authorization-code + PKCE against the organizations endpoints with the
Phase 0 client ID and redirect. Load /me/calendars after connection. Establish the
mailbox owner from the default calendar, filter calendars to the same normalized owner,
and fail closed as specified when owner data is absent. Do not persist or log the owner
address.
The multi-select is required and capped at 10. Before saving/testing, reconcile synced selected IDs with the locally returned owned-calendar IDs. If none or only some match, show an account-mismatch/reselection error and make no event calls. Setup copy must say that disconnect/reconnect affects every Microsoft Calendar configuration on the device.
Acceptance: ordinary own calendars appear; shared/delegated calendars do not; a config synced from the same account works; a config synced from a different account fails safely. Estimate: 1–1.5 days.
Depends on: Tasks 5, 6, 11, and 12.
Fetch each selected calendar's calendarView using the fixed window and numeric limits.
Follow only validated @odata.nextLink values. Limit concurrency, enforce one in-flight
promise per cache key, and implement the fixed failure/retry semantics above.
Use a session-memory cache keyed by a one-way in-memory account-owner fingerprint plus
the sorted selected calendar IDs and window. Never persist the fingerprint. Before the
first agenda load, search may populate the same cache once; afterward searchIssues
filters it locally. Clear it on connect, disconnect, selection change, or OAuth terminal
invalidation.
Acceptance: tests prove pagination caps, total-event cap, 429 with both Retry-After forms, long Retry-After abort, 5xx backoff, timeout, 401 force-refresh-once, terminal reconnect, no overlapping fetches, cache isolation, and all-or-nothing retention of the last complete snapshot. Estimate: 2–3 days.
Depends on: Task 13.
Implement the mandatory issue-provider methods:
getHeaders obtains the current OAuth access token;testConnection validates account, selection, and one bounded Graph call;getNewIssuesForBacklog returns the agenda window;searchIssues searches the same cache, fetching once only if needed;getById reuses a fresh cache entry or performs one bounded direct lookup;getIssueLink uses a cached canonical link with a documented work/school fallback;issueDisplay shows only non-sensitive basic fields.Do not register createIssue, updateIssue, deleteIssue, comments, time-block
methods, or push field mappings. Do not include Graph event bodies in mapped objects.
Acceptance: a static/spy test proves no write HTTP method or write provider hook exists;
task creation gets title, dueWithTime or dueDay, duration/time estimate, and source
link as appropriate. Estimate: 1–1.5 days.
Depends on: Tasks 10 and 14.
Register build/copy and discovery atomically:
packages/plugin-dev/scripts/build-all.jssrc/app/plugins/plugin.service.tselectron/bundled-plugin-ids.test.cjs (verification; change only if the test itself
needs no new behavior)Acceptance: built assets include manifest.json, plugin.js, icon.svg, and
i18n/en.json; the asset path and reserved manifest ID cannot drift. Estimate: 0.5 day.
Depends on: working implementation.
Update user documentation in the same feature PR:
docs/wiki/3.07-Issue-Integration-Comparison.mddocs/wiki/4.24-Integrations.mddocs/wiki/3.05-Web-App-vs-Desktop.mddocs/wiki/3.06-User-Data.mdDocument exact scopes, read-only behavior, Electron-only support, one local account, synced selection versus unsynced credentials, university admin approval, and storage: OAuth tokens in local-only IndexedDB plus basic event metadata/source URLs in the existing unencrypted calendar local-storage cache. State when each cache is retained or purged. Estimate: 0.5–1 day.
Depends on: all implementation tasks.
Run:
npm run checkFile <filepath> for every changed .ts or .scss file;npm test, npm run typecheck, and npm run build;node --test electron/bundled-plugin-ids.test.cjs;npm run plugins:build and a production build appropriate to the release branch.Manual matrix:
Pilot with the reporting user before general release. A successful pilot means they can connect without an iCal URL, select their university calendars, plan from the agenda, create a task snapshot, and open the Outlook event without broader permissions. Estimate: 1.5–2 days plus user availability.
offline_access and delegated Calendars.ReadBasic are requested.127.0.0.1.nextLink values cannot move a bearer request to another host.pluginConfig.Ship only when all are true:
Consider these only after real demand and a separate design review:
Calendars.ReadWrite, conflict semantics, and a much larger trust surface.Calendars.ReadBasic delegated permissionwebLink