ADR-004: Push Notification Architecture for Inbox Monitoring
Status: Accepted Date: December 17, 2025 Decision Makers: CODITECT Engineering Team
Context
CODITECT needs to monitor Gmail inboxes for new messages in real-time to enable:
- Meeting Follow-up Detection: Find emails related to meetings
- RSVP Processing: Detect meeting responses
- AI Email Analysis: Process incoming emails with AI
- Workflow Triggers: Start workflows based on emails
Gmail API offers two approaches for inbox monitoring:
- Polling: Periodically check for new messages
- Push Notifications: Gmail notifies us via Cloud Pub/Sub
Decision
We will use Gmail Push Notifications via Cloud Pub/Sub as the primary mechanism for inbox monitoring, with polling as a fallback.
Architecture
┌─────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Gmail │──────▶│ Cloud Pub/Sub │──────▶│ CODITECT │
│ Inbox │ push │ (Topic) │ push │ Inbox Watcher │
└─────────────┘ └─────────────────┘ └─────────────────┘
│
▼
┌─────────────────┐
│ History API │
│ (Get Messages) │
└─────────────────┘
Implementation
Step 1: Set Up Watch
async def start_watch(self, label_ids: List[str] = None) -> str:
"""Start Gmail push notifications."""
result = await self.gmail_client.watch(
topic_name=f'projects/{self.project_id}/topics/gmail-push',
label_ids=label_ids or ['INBOX'],
)
self._history_id = result['historyId']
return result['expiration']
Step 2: Receive Pub/Sub Notifications
def _handle_pubsub_message(self, message):
"""Handle push notification from Gmail."""
data = json.loads(message.data.decode())
history_id = data['historyId']
email_address = data['emailAddress']
# Fetch new messages since last history ID
asyncio.create_task(self._process_history(history_id))
message.ack()
Step 3: Fetch New Messages
async def _process_history(self, new_history_id: str):
"""Get new messages via History API."""
history = await self.gmail_client.list_history(
start_history_id=self._history_id,
history_types=['messageAdded'],
)
for record in history.get('history', []):
for added in record.get('messagesAdded', []):
message = await self.gmail_client.get_message(
added['message']['id']
)
await self._handle_new_message(message)
self._history_id = new_history_id
Watch Renewal
Gmail watches expire after 7 days. We must renew before expiration.
class WatchRenewalScheduler:
"""Automatically renew Gmail watch before expiration."""
RENEWAL_BUFFER = timedelta(hours=24) # Renew 1 day before expiry
async def schedule_renewal(self, expiration: str):
"""Schedule watch renewal."""
expiry = datetime.fromtimestamp(int(expiration) / 1000)
renewal_time = expiry - self.RENEWAL_BUFFER
await self.scheduler.schedule(
task_id='gmail_watch_renewal',
trigger_time=renewal_time,
callback=self._renew_watch,
)
async def _renew_watch(self):
"""Renew the Gmail watch."""
await self.inbox_watcher.stop()
await self.inbox_watcher.start()
Fallback: Polling
If Pub/Sub fails or isn't available, fall back to polling.
class PollingFallback:
"""Polling fallback for environments without Pub/Sub."""
POLL_INTERVAL = 60 # seconds
async def start_polling(self, label_ids: List[str] = None):
"""Start polling for new messages."""
while self._running:
messages = await self.gmail_client.list_messages(
query='is:unread',
label_ids=label_ids or ['INBOX'],
)
for msg in messages.get('messages', []):
if msg['id'] not in self._seen_ids:
await self._handle_new_message(msg['id'])
self._seen_ids.add(msg['id'])
await asyncio.sleep(self.POLL_INTERVAL)
Consequences
Positive
- Real-time: Messages processed within seconds
- Efficient: No wasted API calls polling empty inbox
- Scalable: Pub/Sub handles high message volumes
- Reliable: Pub/Sub guarantees at-least-once delivery
Negative
- Complexity: Requires Pub/Sub setup
- GCP Dependency: Needs Google Cloud project
- Watch Expiration: Must handle 7-day renewal
- History Gaps: May miss messages if history ID expires
Mitigation
- Automated watch renewal scheduler
- Periodic full sync to catch missed messages
- Polling fallback for non-GCP environments
- Clear setup documentation
Pub/Sub Setup Requirements
# Create topic (one-time)
gcloud pubsub topics create gmail-push
# Create subscription
gcloud pubsub subscriptions create gmail-push-sub \
--topic=gmail-push \
--push-endpoint=https://your-domain.com/webhook/gmail
# Grant Gmail permission to publish
gcloud pubsub topics add-iam-policy-binding gmail-push \
--member="serviceAccount:gmail-api-push@system.gserviceaccount.com" \
--role="roles/pubsub.publisher"
Cost Analysis
| Resource | Free Tier | Our Usage |
|---|---|---|
| Pub/Sub Messages | 10GB/month | < 1GB/month |
| Pub/Sub Delivery | First 10M deliveries | < 100K/month |
| Total Cost | $0 | $0 |
Related Decisions
- ADR-001: Email Provider Abstraction
- ADR-002: Zero-Cost Architecture
- ADR-003: OAuth Strategy
Document Control:
- Created: December 17, 2025
- Author: CODITECT Engineering Team