Calendar sync sounds simple: read events from Google, read events from Outlook, show them together. In practice it involves OAuth2 token refresh chains that break in subtle ways, push operations that must not loop, and an iCal export that nobody requests until the day they want to subscribe from their phone.
This article covers how I built the ExternalCalendarSync system in backend/go/calendar/external_sync.go, including the token refresh pattern, the bidirectional guard, and what production failures revealed.
The core type is a struct with callbacks rather than direct database dependencies:
type ExternalCalendarSync struct {
GetTokensForUser func(userID, provider string) (
accessToken, refreshToken string,
expiry time.Time,
found bool,
)
SaveToken func(userID, provider, accessToken, refreshToken string, expiry time.Time)
HTTPClient *http.Client // nil = default 15s timeout
}
Using callbacks instead of a *mongo.Database field makes this struct testable without a real database. In tests, GetTokensForUser returns pre-seeded tokens and SaveToken records what was persisted. The HTTPClient field is overridden with a mock transport to intercept API calls.
Both credential functions read from environment variables at the moment they are called, not at struct initialization:
func gmailCreds() (clientID, clientSecret string) {
return os.Getenv("GMAIL_CLIENT_ID"), os.Getenv("GMAIL_CLIENT_SECRET")
}
func outlookCreds() (clientID, clientSecret string) {
return os.Getenv("OUTLOOK_CLIENT_ID"), os.Getenv("OUTLOOK_CLIENT_SECRET")
}
This matters because credentials are loaded from MongoDB at startup via applyIntegrationConfigFromDB, which calls os.Setenv after the struct is created. If credentials were captured at struct creation time, the fields would be empty strings for the first several seconds of the application lifecycle.
Capturing env vars at struct init and reading them at call time are not equivalent when startup involves async credential loading from a database. Always read at call time.
The push operation fires when a user creates or edits an event in the internal calendar. It must not push events that were imported from external sources, or the external calendar would push them back, creating an infinite loop.
func (s *ExternalCalendarSync) PushEvent(userID string, ev *Event) {
if s == nil || ev.Source != "" {
return // never push events imported from external sources
}
go s.pushGoogle(userID, ev)
go s.pushOutlook(userID, ev)
}
The guard is the ev.Source != "" check. Every event pulled from Google Calendar is stored with Source: "google". Every event pulled from Outlook gets Source: "outlook". Only events created natively in KaryoSpace have an empty Source field and are eligible for push.
Push fires two goroutines and returns immediately. Errors are logged but never returned to the caller. External calendar sync is best-effort. If Google is having an outage, the user's internal event still saves. They can resync later.
func (s *ExternalCalendarSync) SyncUser(userID, orgID string, store EventStore) (googleCount, outlookCount int, err error) {
if s == nil { return }
googleCount, err = s.pullGoogle(userID, orgID, store)
if err != nil {
log.Printf("calendar: Google pull failed for %s: %v", userID, err)
err = nil // clear error — continue to Outlook
}
outlookCount, err = s.pullOutlook(userID, orgID, store)
if err != nil {
log.Printf("calendar: Outlook pull failed for %s: %v", userID, err)
err = nil
}
return
}
Google failure does not block Outlook sync. Each provider is independent. The final error return is always nil if both providers ran, because a partial sync is better than no sync.
The Google OAuth2 library handles token refresh automatically when using TokenSource. The key is wiring the SaveToken callback so refreshed tokens are persisted:
cfg := &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
Endpoint: google.Endpoint,
Scopes: []string{"https://www.googleapis.com/auth/calendar"},
}
tok := &oauth2.Token{
AccessToken: accessToken,
RefreshToken: refreshToken,
Expiry: expiry,
}
// TokenSource auto-refreshes when the token is about to expire.
// We wrap it to intercept refreshes and call SaveToken.
ts := cfg.TokenSource(ctx, tok)
After each API call that triggers a refresh, the new token is passed to s.SaveToken(userID, "gmail", newToken.AccessToken, newToken.RefreshToken, newToken.Expiry). Without this, the refreshed token is discarded and every cold start requires the user to re-authenticate.
The health endpoint surfaced this error on production:
calendar: Google pull failed for admin@karyospace.com:
token refresh: oauth2: "invalid_grant" "Token has been expired or revoked."
This means the refresh token itself was revoked, either by the user revoking access in their Google Account security settings, or by Google revoking it after a long period of inactivity. There is no programmatic fix. The user must reconnect their account and go through the OAuth2 consent flow again.
Refresh tokens can be revoked externally at any time. Surface the failure clearly to the user and provide a one-click reconnect path. Do not hide it in logs.
Pulled events are stored with upsert by external event ID. Re-pulling the same events is idempotent:
// Upsert by google_event_id — safe to re-run on every sync
filter := bson.M{
"user_id": userID,
"google_event_id": googleEvent.Id,
}
update := bson.M{"$set": bson.M{
"title": googleEvent.Summary,
"start": start,
"end": end,
"source": "google",
"location": googleEvent.Location,
}}
The source: "google" field on every pulled event is what the push guard reads. This is the same field that prevents the infinite loop described earlier.
The GET /calendar/export/ical endpoint writes a standards-compliant RFC 5545 file. Users link to it from their phone's native calendar app or Apple Calendar:
w.Header().Set("Content-Type", "text/calendar")
w.Header().Set("Content-Disposition", `attachment; filename="karyospace.ics"`)
fmt.Fprintln(w, "BEGIN:VCALENDAR")
fmt.Fprintln(w, "VERSION:2.0")
fmt.Fprintln(w, "PRODID:-//KaryoSpace//EN")
for _, ev := range events {
fmt.Fprintln(w, "BEGIN:VEVENT")
fmt.Fprintf(w, "UID:%s\r\n", ev.ID.Hex())
fmt.Fprintf(w, "DTSTART:%s\r\n", ev.Start.UTC().Format("20060102T150405Z"))
fmt.Fprintf(w, "DTEND:%s\r\n", ev.End.UTC().Format("20060102T150405Z"))
fmt.Fprintf(w, "SUMMARY:%s\r\n", escapeIcal(ev.Title))
fmt.Fprintln(w, "END:VEVENT")
}
fmt.Fprintln(w, "END:VCALENDAR")
Nobody asks for iCal export during design reviews. It becomes critical the first time a user wants to see their work events on their personal iPhone calendar without granting OAuth access to a second account. A simple static endpoint covers the use case entirely.
The GET /calendar/availability endpoint answers questions like "is Suman free Tuesday at 3pm?" It queries the event store for the requested time window and returns whether any events overlap:
// Returns []TimeSlot where Available=false for booked windows
func checkAvailability(userID string, from, to time.Time, store EventStore) []TimeSlot
The home assistant uses this to answer natural language calendar questions. When the AI routes a query through the calendar RAG path, it calls this endpoint before constructing the response so the answer reflects actual free/busy state rather than guessing from event titles.
Calendar sync looks like a solved problem until you build it. The token refresh timing issue, the push loop guard, and the iCal export are all details that only become obvious once you have real users connecting real accounts. Building in Go with clean callback interfaces made each of these testable independently before integrating them.