Tools: Building Real-Time Gmail Integration with Google Meet Scheduling in Go

Tools: Building Real-Time Gmail Integration with Google Meet Scheduling in Go

Source: Dev.to

The Challenge ## Architecture Overview ## Part 1: Gmail Push Notifications ## Setting Up Pub/Sub ## The historyId Gotcha ## History-Based Incremental Sync ## Watch Renewal ## Part 2: Domain-Wide Delegation for Calendar ## JWT Bearer Token Flow ## Creating Meetings with Meet Links ## Part 3: Availability Checking ## Key Lessons Learned ## Conclusion Ever needed to build a system where AI agents can receive emails in real-time and schedule meetings on behalf of users? Here's how I architected a production-grade solution using Gmail Push API, Google Cloud Pub/Sub, and domain-wide delegation. Traditional email polling is inefficient and introduces latency. We needed: Gmail's Push API publishes notifications to a Pub/Sub topic when emails arrive: Gmail sends historyId as a number, but many JSON parsers expect strings. This caused silent failures: Instead of fetching all emails, we track the last processed historyId in Redis and only fetch new messages: Gmail watches expire after 7 days. A Cloud Scheduler job renews them every 5 days: The magic of domain-wide delegation: a single service account can act on behalf of any user in your Google Workspace domain - no individual OAuth flows required. Calculate available slots by merging busy times from multiple sources: Always return 200 for invalid webhooks - Pub/Sub retries on non-2xx responses. Return 200 with a skip message for malformed payloads. Use json.Number for numeric IDs - Google APIs sometimes send numbers where you expect strings. History can expire - If historyId is too old, Gmail returns 404. Fall back to scanning recent messages. Buffer your slots - Add configurable buffer time before/after meetings for travel or prep. Cache access tokens - JWT exchange is expensive. Cache tokens until ~30 seconds before expiry. This architecture enables AI agents to process emails in real-time and schedule meetings autonomously. The combination of Pub/Sub push notifications and domain-wide delegation eliminates polling overhead and individual auth flows. The system has been running in production handling thousands of emails daily with sub-second notification latency. What challenges have you faced with Google API integrations? Share in the comments! Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse CODE_BLOCK: Gmail Inbox → Gmail API → Pub/Sub Topic → Webhook → Backend → AI Agent ↓ Google Calendar API ↓ Google Meet Link Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: Gmail Inbox → Gmail API → Pub/Sub Topic → Webhook → Backend → AI Agent ↓ Google Calendar API ↓ Google Meet Link CODE_BLOCK: Gmail Inbox → Gmail API → Pub/Sub Topic → Webhook → Backend → AI Agent ↓ Google Calendar API ↓ Google Meet Link COMMAND_BLOCK: # Create topic and push subscription gcloud pubsub topics create gmail-inbound gcloud pubsub subscriptions create gmail-inbound-sub \ --topic=gmail-inbound \ --push-endpoint="https://your-api.com/webhooks/gmail" # Grant Gmail permission to publish gcloud pubsub topics add-iam-policy-binding gmail-inbound \ --member="serviceAccount:[email protected]" \ --role="roles/pubsub.publisher" Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: # Create topic and push subscription gcloud pubsub topics create gmail-inbound gcloud pubsub subscriptions create gmail-inbound-sub \ --topic=gmail-inbound \ --push-endpoint="https://your-api.com/webhooks/gmail" # Grant Gmail permission to publish gcloud pubsub topics add-iam-policy-binding gmail-inbound \ --member="serviceAccount:[email protected]" \ --role="roles/pubsub.publisher" COMMAND_BLOCK: # Create topic and push subscription gcloud pubsub topics create gmail-inbound gcloud pubsub subscriptions create gmail-inbound-sub \ --topic=gmail-inbound \ --push-endpoint="https://your-api.com/webhooks/gmail" # Grant Gmail permission to publish gcloud pubsub topics add-iam-policy-binding gmail-inbound \ --member="serviceAccount:[email protected]" \ --role="roles/pubsub.publisher" CODE_BLOCK: // Broken - fails silently type gmailNotification struct { HistoryID string `json:"historyId"` // Wrong! } // Fixed - handles both string and number type gmailNotification struct { HistoryID json.Number `json:"historyId"` // Correct! } // Usage historyIDStr := notification.HistoryID.String() Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: // Broken - fails silently type gmailNotification struct { HistoryID string `json:"historyId"` // Wrong! } // Fixed - handles both string and number type gmailNotification struct { HistoryID json.Number `json:"historyId"` // Correct! } // Usage historyIDStr := notification.HistoryID.String() CODE_BLOCK: // Broken - fails silently type gmailNotification struct { HistoryID string `json:"historyId"` // Wrong! } // Fixed - handles both string and number type gmailNotification struct { HistoryID json.Number `json:"historyId"` // Correct! } // Usage historyIDStr := notification.HistoryID.String() CODE_BLOCK: func (s *Service) HandleNotification(ctx context.Context, email, historyID string) error { // Get last processed history ID from Redis lastHistoryID := s.redis.Get(ctx, "gmail:history:"+email) // Fetch only new messages since last sync messages := s.gmail.History.List("me"). StartHistoryId(lastHistoryID). HistoryTypes("messageAdded").Do() // Process each message for _, msg := range messages { s.processMessage(ctx, msg) } // Update checkpoint s.redis.Set(ctx, "gmail:history:"+email, historyID) return nil } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: func (s *Service) HandleNotification(ctx context.Context, email, historyID string) error { // Get last processed history ID from Redis lastHistoryID := s.redis.Get(ctx, "gmail:history:"+email) // Fetch only new messages since last sync messages := s.gmail.History.List("me"). StartHistoryId(lastHistoryID). HistoryTypes("messageAdded").Do() // Process each message for _, msg := range messages { s.processMessage(ctx, msg) } // Update checkpoint s.redis.Set(ctx, "gmail:history:"+email, historyID) return nil } CODE_BLOCK: func (s *Service) HandleNotification(ctx context.Context, email, historyID string) error { // Get last processed history ID from Redis lastHistoryID := s.redis.Get(ctx, "gmail:history:"+email) // Fetch only new messages since last sync messages := s.gmail.History.List("me"). StartHistoryId(lastHistoryID). HistoryTypes("messageAdded").Do() // Process each message for _, msg := range messages { s.processMessage(ctx, msg) } // Update checkpoint s.redis.Set(ctx, "gmail:history:"+email, historyID) return nil } CODE_BLOCK: gcloud scheduler jobs create http gmail-watch-renewal \ --schedule="0 0 */5 * *" \ --uri="https://your-api.com/internal/gmail/watch/renew" \ --http-method=POST Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: gcloud scheduler jobs create http gmail-watch-renewal \ --schedule="0 0 */5 * *" \ --uri="https://your-api.com/internal/gmail/watch/renew" \ --http-method=POST CODE_BLOCK: gcloud scheduler jobs create http gmail-watch-renewal \ --schedule="0 0 */5 * *" \ --uri="https://your-api.com/internal/gmail/watch/renew" \ --http-method=POST CODE_BLOCK: func (p *AuthProvider) GetHTTPClient(ctx context.Context, userEmail string) *http.Client { // Create JWT claims with 'sub' for user impersonation claims := map[string]interface{}{ "iss": p.serviceAccountEmail, "sub": userEmail, // The user we're acting as "scope": "https://www.googleapis.com/auth/calendar", "aud": "https://oauth2.googleapis.com/token", "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(), } // Sign with RSA private key token := jwt.Sign(claims, p.privateKey) // Exchange for access token accessToken := p.exchangeJWT(token) return &http.Client{ Transport: &bearerTransport{token: accessToken}, } } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: func (p *AuthProvider) GetHTTPClient(ctx context.Context, userEmail string) *http.Client { // Create JWT claims with 'sub' for user impersonation claims := map[string]interface{}{ "iss": p.serviceAccountEmail, "sub": userEmail, // The user we're acting as "scope": "https://www.googleapis.com/auth/calendar", "aud": "https://oauth2.googleapis.com/token", "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(), } // Sign with RSA private key token := jwt.Sign(claims, p.privateKey) // Exchange for access token accessToken := p.exchangeJWT(token) return &http.Client{ Transport: &bearerTransport{token: accessToken}, } } CODE_BLOCK: func (p *AuthProvider) GetHTTPClient(ctx context.Context, userEmail string) *http.Client { // Create JWT claims with 'sub' for user impersonation claims := map[string]interface{}{ "iss": p.serviceAccountEmail, "sub": userEmail, // The user we're acting as "scope": "https://www.googleapis.com/auth/calendar", "aud": "https://oauth2.googleapis.com/token", "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(), } // Sign with RSA private key token := jwt.Sign(claims, p.privateKey) // Exchange for access token accessToken := p.exchangeJWT(token) return &http.Client{ Transport: &bearerTransport{token: accessToken}, } } COMMAND_BLOCK: func (s *SchedulerService) CreateMeeting(ctx context.Context, input CreateMeetingInput) (*Meeting, error) { // Check for conflicts if conflicts := s.repo.FindConflicts(ctx, input.HostID, input.Start, input.End); len(conflicts) > 0 { return nil, ErrTimeSlotConflict } // Get authorized client for this user client := s.authProvider.GetHTTPClient(ctx, input.HostEmail) // Create event with automatic Meet link event := &calendar.Event{ Summary: input.Title, Start: &calendar.EventDateTime{DateTime: input.Start.Format(time.RFC3339)}, End: &calendar.EventDateTime{DateTime: input.End.Format(time.RFC3339)}, ConferenceData: &calendar.ConferenceData{ CreateRequest: &calendar.CreateConferenceRequest{ RequestId: uuid.NewString(), ConferenceSolutionKey: &calendar.ConferenceSolutionKey{ Type: "hangoutsMeet", }, }, }, } created := calendarService.Events.Insert("primary", event). ConferenceDataVersion(1).Do() return &Meeting{ MeetLink: created.HangoutLink, // ... }, nil } Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: func (s *SchedulerService) CreateMeeting(ctx context.Context, input CreateMeetingInput) (*Meeting, error) { // Check for conflicts if conflicts := s.repo.FindConflicts(ctx, input.HostID, input.Start, input.End); len(conflicts) > 0 { return nil, ErrTimeSlotConflict } // Get authorized client for this user client := s.authProvider.GetHTTPClient(ctx, input.HostEmail) // Create event with automatic Meet link event := &calendar.Event{ Summary: input.Title, Start: &calendar.EventDateTime{DateTime: input.Start.Format(time.RFC3339)}, End: &calendar.EventDateTime{DateTime: input.End.Format(time.RFC3339)}, ConferenceData: &calendar.ConferenceData{ CreateRequest: &calendar.CreateConferenceRequest{ RequestId: uuid.NewString(), ConferenceSolutionKey: &calendar.ConferenceSolutionKey{ Type: "hangoutsMeet", }, }, }, } created := calendarService.Events.Insert("primary", event). ConferenceDataVersion(1).Do() return &Meeting{ MeetLink: created.HangoutLink, // ... }, nil } COMMAND_BLOCK: func (s *SchedulerService) CreateMeeting(ctx context.Context, input CreateMeetingInput) (*Meeting, error) { // Check for conflicts if conflicts := s.repo.FindConflicts(ctx, input.HostID, input.Start, input.End); len(conflicts) > 0 { return nil, ErrTimeSlotConflict } // Get authorized client for this user client := s.authProvider.GetHTTPClient(ctx, input.HostEmail) // Create event with automatic Meet link event := &calendar.Event{ Summary: input.Title, Start: &calendar.EventDateTime{DateTime: input.Start.Format(time.RFC3339)}, End: &calendar.EventDateTime{DateTime: input.End.Format(time.RFC3339)}, ConferenceData: &calendar.ConferenceData{ CreateRequest: &calendar.CreateConferenceRequest{ RequestId: uuid.NewString(), ConferenceSolutionKey: &calendar.ConferenceSolutionKey{ Type: "hangoutsMeet", }, }, }, } created := calendarService.Events.Insert("primary", event). ConferenceDataVersion(1).Do() return &Meeting{ MeetLink: created.HangoutLink, // ... }, nil } CODE_BLOCK: func (s *AvailabilityService) GetSlots(ctx context.Context, req Request) []Slot { // 1. Load availability rules (e.g., Mon-Fri 9AM-5PM) rules := s.repo.GetRules(ctx, req.HostID) // 2. Fetch Google Calendar busy times freeBusy := s.calendar.FreeBusy(ctx, req.Start, req.End) // 3. Fetch local meeting conflicts localMeetings := s.repo.GetMeetings(ctx, req.HostID, req.Start, req.End) // 4. Merge and find gaps busyBlocks := merge(freeBusy, localMeetings) return generateSlots(rules, busyBlocks, req.SlotDuration) } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: func (s *AvailabilityService) GetSlots(ctx context.Context, req Request) []Slot { // 1. Load availability rules (e.g., Mon-Fri 9AM-5PM) rules := s.repo.GetRules(ctx, req.HostID) // 2. Fetch Google Calendar busy times freeBusy := s.calendar.FreeBusy(ctx, req.Start, req.End) // 3. Fetch local meeting conflicts localMeetings := s.repo.GetMeetings(ctx, req.HostID, req.Start, req.End) // 4. Merge and find gaps busyBlocks := merge(freeBusy, localMeetings) return generateSlots(rules, busyBlocks, req.SlotDuration) } CODE_BLOCK: func (s *AvailabilityService) GetSlots(ctx context.Context, req Request) []Slot { // 1. Load availability rules (e.g., Mon-Fri 9AM-5PM) rules := s.repo.GetRules(ctx, req.HostID) // 2. Fetch Google Calendar busy times freeBusy := s.calendar.FreeBusy(ctx, req.Start, req.End) // 3. Fetch local meeting conflicts localMeetings := s.repo.GetMeetings(ctx, req.HostID, req.Start, req.End) // 4. Merge and find gaps busyBlocks := merge(freeBusy, localMeetings) return generateSlots(rules, busyBlocks, req.SlotDuration) } - Real-time email notifications when messages arrive - Meeting scheduling on behalf of users without individual OAuth flows - Reliable message processing with no duplicates or missed emails - Always return 200 for invalid webhooks - Pub/Sub retries on non-2xx responses. Return 200 with a skip message for malformed payloads. - Use json.Number for numeric IDs - Google APIs sometimes send numbers where you expect strings. - History can expire - If historyId is too old, Gmail returns 404. Fall back to scanning recent messages. - Buffer your slots - Add configurable buffer time before/after meetings for travel or prep. - Cache access tokens - JWT exchange is expensive. Cache tokens until ~30 seconds before expiry.