# Gmail Push Notifications Revisit — Implementation Plan **Date**: 2026-02-13 **Status**: Planned **Goal**: Make Gmail notifications operationally reliable and well-documented ## Overview The Gmail watcher currently supports two modes: 1. **Pub/Sub push notifications** (Google sends HTTP POST to `/gmail/push`) 2. **Polling fallback** (History API queries every N seconds) Current implementation has operational ambiguity around: - Deployment patterns (public webhook vs Pub/Sub pull vs hybrid) - GCP resource setup (topic/subscription/IAM relationships) - Network reachability constraints (Tailscale-only gateways) - Configuration surface (implicit vs explicit controls) - Error diagnostics (vague failure messages) This plan addresses these gaps without changing core functionality. --- ## Problem Analysis ### Current Behavior **Push setup flow** (`src/automation/gmail.ts:222-253`): 1. `resolvePubSubTopicName()` reads `pubsub_topic` config or `FLYNN_GMAIL_PUBSUB_TOPIC` env var 2. Calls `gmail.users.watch()` with the topic 3. If watch fails → logs warning, continues with polling only 4. If watch succeeds → schedules renewal every 6 days **Issues**: - **Topic ownership mismatch**: OAuth credentials' `project_id` may differ from the Pub/Sub topic's project - **Network reachability**: Google cannot reach Tailscale-only endpoints (`server.tailscale_only: true`) - **Implicit disable**: No explicit way to disable push without deleting `pubsub_topic` field - **Poor error messages**: "Invalid topicName" error includes no actionable guidance - **Undocumented subscription**: Users must manually create Pub/Sub subscription → push endpoint mapping - **Doctor check gap**: `flynn doctor` validates OAuth but not Pub/Sub setup ### Deployment Patterns | Pattern | Reachability | Flynn Config | GCP Setup | Use Case | |---------|--------------|--------------|-----------|----------| | **Public webhook** | Public IP + DNS | `pubsub_topic` set | Topic + push subscription → `POST https://example.com/gmail/push` | Production deployment with public ingress | | **Tailscale Serve** | Tailscale funnel | `pubsub_topic` set, `server.tailscale.funnel: true` | Topic + push subscription → `POST https://flynn.ts.net/gmail/push` | Private deployment with Tailscale funnel enabled | | **Pub/Sub pull** | Any (no inbound) | `pubsub_subscription_id` set | Topic + pull subscription | Private deployment, Flynn polls subscription | | **Polling only** | Any (no inbound) | `pubsub_topic` omitted | None | Development, no GCP setup | | **Hybrid (recommended)** | Any | `pubsub_topic` + `pubsub_subscription_id` | Topic + push subscription + pull subscription | Push when reachable, pull fallback, History API tertiary | --- ## Recommended Changes ### 1. Config Schema Additions **File**: `src/config/schema.ts` Add new fields to `gmailSchema`: ```typescript const gmailSchema = z.object({ enabled: z.boolean().default(false), credentials_file: z.string().optional(), token_file: z.string().default('~/.config/flynn/gmail-token.json'), // ── Push notifications ──────────────────────────────────────────────── /** * Pub/Sub topic for Gmail push notifications (watch API). * Format: projects//topics/ or just (auto-prefixed). * If omitted, push notifications are disabled (polling only). */ pubsub_topic: z.string().optional(), /** * Pub/Sub subscription ID for pull-based message retrieval. * Format: projects//subscriptions/ or just . * When set, Flynn periodically pulls messages from this subscription in addition * to waiting for push notifications at POST /gmail/push. * Enables hybrid mode: push when reachable, pull fallback. */ pubsub_subscription_id: z.string().optional(), /** * Pull interval for Pub/Sub subscription polling (hybrid mode). * Only used when pubsub_subscription_id is set. Default: 60s. */ pubsub_pull_interval: z.string().default('60s'), /** * Maximum messages to pull per request when using pull mode. Default: 10. */ pubsub_max_messages: z.number().min(1).max(100).default(10), /** * Disable watch API even if pubsub_topic is set. * Useful for debugging or when push endpoint is temporarily unreachable. */ disable_push: z.boolean().default(false), // ── Existing fields ──────────────────────────────────────────────────── watch_labels: z.array(z.string()).default(['INBOX']), poll_interval: z.string().default('300s'), // History API fallback history_start: z.string().optional(), output: z.object({ channel: z.string().min(1), peer: z.string().min(1), }), message: z.string().default('New email from {{from}}: {{subject}}\n\n{{snippet}}'), }).optional(); ``` **Defaults**: - `pubsub_topic`: `undefined` (no push) - `pubsub_subscription_id`: `undefined` (no pull) - `pubsub_pull_interval`: `'60s'` - `pubsub_max_messages`: `10` - `disable_push`: `false` - `poll_interval`: `'300s'` (History API fallback) --- ### 2. Implementation Changes #### 2.1 Add Pub/Sub Pull Support **File**: `src/automation/gmail.ts` Add new method `setupPullSubscription()`: ```typescript private pullSubscriptionTimer?: ReturnType; private subscriptionPath?: string; /** * Set up Pub/Sub pull subscription for periodic message retrieval. * Alternative/fallback to push notifications when endpoint is unreachable. */ private async setupPullSubscription(): Promise { if (!this.config.pubsub_subscription_id) { return; // Pull mode not configured } const subId = this.config.pubsub_subscription_id.trim(); // Allow shorthand: just the subscription id (e.g. "gmail-pull") if (!subId.includes('/')) { if (!this.googleProjectId) { console.warn( `GmailWatcher: pubsub_subscription_id '${subId}' must be fully qualified ` + `(projects//subscriptions/) because project_id was not found in credentials` ); return; } this.subscriptionPath = `projects/${this.googleProjectId}/subscriptions/${subId}`; } else { const isValid = /^projects\/[^/]+\/subscriptions\/[^/]+$/.test(subId); if (!isValid) { console.warn( `GmailWatcher: Invalid pubsub_subscription_id '${subId}'. ` + `Expected: projects//subscriptions/` ); return; } this.subscriptionPath = subId; } console.log(`GmailWatcher: Pull subscription registered (${this.subscriptionPath})`); // Start periodic pull const pullMs = parseInterval(this.config.pubsub_pull_interval ?? '60s'); this.pullSubscriptionTimer = setInterval(() => { this.pullSubscriptionMessages().catch((err) => { console.error('GmailWatcher: Pull subscription error —', err instanceof Error ? err.message : err); }); }, pullMs); } /** * Pull messages from the Pub/Sub subscription and process them. */ private async pullSubscriptionMessages(): Promise { if (!this.subscriptionPath || !this.oauth2Client) { return; } try { const { PubSub } = await import('@google-cloud/pubsub'); const pubsub = new PubSub({ projectId: this.googleProjectId, authClient: this.oauth2Client, }); const subscription = pubsub.subscription(this.subscriptionPath); const [messages] = await subscription.pull({ maxMessages: this.config.pubsub_max_messages ?? 10, }); for (const message of messages) { if (message.data) { await this.handlePushNotification(message.data.toString('base64')); } // Acknowledge the message await subscription.ack(message); } } catch (error) { console.error( 'GmailWatcher: Failed to pull Pub/Sub messages —', error instanceof Error ? error.message : error ); } } ``` Update `connect()` to call `setupPullSubscription()`: ```typescript async connect(): Promise { this._status = 'connecting'; try { this.oauth2Client = await this.authorize(); } catch (error) { const errMsg = error instanceof Error ? error.message : 'Unknown error'; console.error(`GmailWatcher: Authorization failed — ${errMsg}`); console.error('GmailWatcher: Run "flynn gmail-auth" to set up Gmail credentials.'); this._status = 'error'; return; } // Set up Gmail push watch (Pub/Sub) if not disabled if (!this.config.disable_push) { try { await this.setupWatch(); } catch (error) { const errMsg = error instanceof Error ? error.message : 'Unknown error'; const hint = this.buildWatchErrorHint(errMsg); console.warn(`GmailWatcher: Watch setup failed (push disabled) — ${errMsg}${hint}`); } } else { console.log('GmailWatcher: Push notifications disabled (disable_push: true)'); } // Set up Pub/Sub pull subscription (hybrid mode) await this.setupPullSubscription(); // Start History API polling fallback const pollMs = parseInterval(this.config.poll_interval ?? '300s'); this.pollTimer = setInterval(() => { this.pollForNewMessages().catch((err) => { console.error('GmailWatcher: Poll error —', err instanceof Error ? err.message : err); }); }, pollMs); this._status = 'connected'; console.log( `GmailWatcher: Connected (` + `push=${!this.config.disable_push && !!this.config.pubsub_topic}, ` + `pull=${!!this.config.pubsub_subscription_id}, ` + `poll_interval=${this.config.poll_interval ?? '300s'})` ); auditLogger?.systemStart('GmailWatcher', { push: !this.config.disable_push && !!this.config.pubsub_topic, pull: !!this.config.pubsub_subscription_id, poll_interval: this.config.poll_interval, }); } ``` Update `disconnect()` to clear pull timer: ```typescript async disconnect(): Promise { if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = undefined; } if (this.watchTimer) { clearTimeout(this.watchTimer); this.watchTimer = undefined; } if (this.pullSubscriptionTimer) { clearInterval(this.pullSubscriptionTimer); this.pullSubscriptionTimer = undefined; } this.oauth2Client = undefined; this._status = 'disconnected'; auditLogger?.systemStop('GmailWatcher'); } ``` #### 2.2 Improve Error Messages **File**: `src/automation/gmail.ts` Add helper method `buildWatchErrorHint()`: ```typescript /** * Build a helpful error hint based on the watch failure message. */ private buildWatchErrorHint(errMsg: string): string { if (errMsg.includes('Invalid topicName')) { const topicHint = this.googleProjectId ? `projects/${this.googleProjectId}/topics/gmail-push` : '/topics/gmail-push'; return `\n` + ` Tip: Set automation.gmail.pubsub_topic to "${topicHint}"\n` + ` Ensure the topic exists in GCP and the subscription is configured to POST to /gmail/push\n` + ` Or set automation.gmail.pubsub_subscription_id for pull-based mode (no webhook required)`; } if (errMsg.includes('Permission denied') || errMsg.includes('Forbidden')) { return `\n` + ` Tip: Grant Gmail API permission to publish to the Pub/Sub topic:\n` + ` gcloud pubsub topics add-iam-policy-binding \\\n` + ` --member=serviceAccount:gmail-api-push@system.gserviceaccount.com \\\n` + ` --role=roles/pubsub.publisher`; } if (errMsg.includes('Not found') || errMsg.includes('404')) { return `\n` + ` Tip: Create the Pub/Sub topic first:\n` + ` gcloud pubsub topics create gmail-push --project=`; } return ''; } ``` Update `setupWatch()` error handling: ```typescript try { await this.setupWatch(); } catch (error) { const errMsg = error instanceof Error ? error.message : 'Unknown error'; const hint = this.buildWatchErrorHint(errMsg); console.warn(`GmailWatcher: Watch setup failed (push disabled) — ${errMsg}${hint}`); } ``` --- ### 3. Doctor Checks **File**: `src/cli/doctor.ts` Enhance `checkGmail` to validate Pub/Sub setup: ```typescript const checkGmail: Check = async (ctx) => { if (!ctx.config) { return { status: 'skip', label: 'Gmail configured', detail: '(config invalid)' }; } const gmail = ctx.config.automation.gmail; if (!gmail?.enabled) { return { status: 'skip', label: 'Gmail configured', detail: '(not enabled)' }; } // 1. Check credentials file const credentialsPath = expandPath(gmail.credentials_file ?? '~/.config/flynn/gmail-credentials.json'); if (!existsSync(credentialsPath)) { return { status: 'fail', label: 'Gmail configured', detail: `credentials file not found: ${credentialsPath}` }; } // 2. Check token file const tokenPath = expandPath(gmail.token_file ?? '~/.config/flynn/gmail-token.json'); if (!existsSync(tokenPath)) { return { status: 'warn', label: 'Gmail configured', detail: 'run `flynn gmail-auth` to authenticate' }; } // 3. Parse credentials to get project_id let projectId: string | undefined; try { const creds = JSON.parse(readFileSync(credentialsPath, 'utf-8')); projectId = creds.installed?.project_id ?? creds.web?.project_id; } catch { // Ignore parse errors — already validated above } // 4. Check Pub/Sub configuration const modes: string[] = []; const warnings: string[] = []; if (gmail.pubsub_topic && !gmail.disable_push) { const topic = gmail.pubsub_topic.trim(); if (!topic.includes('/') && !projectId) { warnings.push('pubsub_topic requires project_id in credentials'); } else { modes.push('push'); // Warn if tailscale_only is enabled (Google cannot reach tailnet-only endpoints) if (ctx.config.server?.tailscale_only && !ctx.config.server?.tailscale?.funnel) { warnings.push('push requires public endpoint or Tailscale funnel'); } } } if (gmail.pubsub_subscription_id) { const sub = gmail.pubsub_subscription_id.trim(); if (!sub.includes('/') && !projectId) { warnings.push('pubsub_subscription_id requires project_id in credentials'); } else { modes.push('pull'); } } if (modes.length === 0) { modes.push('poll'); } else { modes.push('poll-fallback'); } const modeStr = modes.join(' + '); const detail = warnings.length > 0 ? `${modeStr} (⚠️ ${warnings.join(', ')})` : `${modeStr} → ${gmail.output.channel}/${gmail.output.peer}`; return { status: warnings.length > 0 ? 'warn' : 'pass', label: 'Gmail configured', detail, }; }; ``` --- ### 4. Documentation Updates #### 4.1 README.md **File**: `README.md` Update Gmail section (lines 461-512): ```markdown ### Gmail Watcher Monitor a Gmail inbox for new messages and trigger agent responses. Flynn supports three retrieval modes: 1. **Push notifications** (Pub/Sub watch) — Google sends HTTP POST to `/gmail/push` 2. **Pull subscription** (Pub/Sub pull) — Flynn periodically pulls messages from a subscription 3. **History API polling** (fallback) — Flynn queries the History API every N seconds **Recommended deployment**: Hybrid mode (push + pull + polling) for maximum reliability. #### Prerequisites 1. **Google Cloud Project** with Gmail API and Pub/Sub API enabled 2. **OAuth2 credentials** (Desktop application) — download JSON from Cloud Console 3. **Pub/Sub topic** (for push or pull): ```bash gcloud pubsub topics create gmail-push --project=your-project ``` 4. **Grant Gmail API publish permission**: ```bash gcloud pubsub topics add-iam-policy-binding projects/your-project/topics/gmail-push \ --member=serviceAccount:gmail-api-push@system.gserviceaccount.com \ --role=roles/pubsub.publisher ``` 5. **Authenticate**: ```bash flynn gmail-auth ``` #### Deployment Patterns | Mode | Config | GCP Setup | Network | Use Case | |------|--------|-----------|---------|----------| | **Push only** | `pubsub_topic` | Topic + push subscription → `POST https://example.com/gmail/push` | Public IP or Tailscale funnel | Production with ingress | | **Pull only** | `pubsub_subscription_id` | Topic + pull subscription | Any (no inbound) | Private deployment | | **Hybrid** ⭐ | Both | Topic + push + pull subscriptions | Any | Maximum reliability | | **Polling only** | Neither | None | Any | Development/testing | #### Push Notifications Setup When `pubsub_topic` is set, Flynn calls `gmail.users.watch()` to register for push notifications. 1. Create a **push subscription** in GCP: ```bash gcloud pubsub subscriptions create gmail-push-sub \ --topic=gmail-push \ --push-endpoint=https://your-domain.com/gmail/push ``` 2. If using **Tailscale Serve**, enable funnel: ```yaml server: tailscale: serve: true funnel: true # ← Required for Google to reach your endpoint ``` 3. Set `pubsub_topic` in config: ```yaml automation: gmail: enabled: true pubsub_topic: projects/your-project/topics/gmail-push ``` **Important**: Push notifications require a publicly reachable endpoint. If `server.tailscale_only: true` and funnel is disabled, push will fail (Flynn falls back to pull/poll). #### Pull Subscription Setup When `pubsub_subscription_id` is set, Flynn periodically pulls messages from the subscription. 1. Create a **pull subscription** in GCP: ```bash gcloud pubsub subscriptions create gmail-pull-sub \ --topic=gmail-push \ --ack-deadline=60 ``` 2. Set `pubsub_subscription_id` in config: ```yaml automation: gmail: enabled: true pubsub_subscription_id: projects/your-project/topics/gmail-pull-sub pubsub_pull_interval: "60s" ``` **Note**: Pull mode works with any network setup (no inbound connections required). #### Hybrid Mode (Recommended) Combine push + pull for maximum reliability: ```yaml automation: gmail: enabled: true credentials_file: ~/.config/flynn/gmail-credentials.json token_file: ~/.config/flynn/gmail-token.json # Push notifications (when endpoint is reachable) pubsub_topic: projects/your-project/topics/gmail-push # Pull fallback (always works) pubsub_subscription_id: projects/your-project/subscriptions/gmail-pull-sub pubsub_pull_interval: "60s" # History API tertiary fallback poll_interval: "300s" watch_labels: [INBOX] message: "New email from {{from}}: {{subject}}\n\n{{snippet}}" output: channel: telegram peer: "123456789" ``` #### Configuration Reference | Field | Required | Default | Description | |-------|----------|---------|-------------| | `enabled` | no | `false` | Enable the Gmail watcher | | `credentials_file` | yes | — | Path to OAuth2 credentials JSON | | `token_file` | no | `~/.config/flynn/gmail-token.json` | Path to stored refresh token | | `pubsub_topic` | no | — | Pub/Sub topic for push notifications (format: `projects//topics/`) | | `pubsub_subscription_id` | no | — | Pub/Sub subscription for pull mode (format: `projects//subscriptions/`) | | `pubsub_pull_interval` | no | `60s` | Pull interval when using subscription | | `pubsub_max_messages` | no | `10` | Max messages per pull request (1-100) | | `disable_push` | no | `false` | Disable watch API even if `pubsub_topic` is set | | `watch_labels` | no | `[INBOX]` | Gmail labels to watch | | `poll_interval` | no | `300s` | History API fallback interval | | `history_start` | no | — | ISO date — only process emails after this date | | `message` | no | `New email from {{from}}: {{subject}}\n\n{{snippet}}` | Template for agent message | | `output.channel` | yes | — | Channel to route responses (e.g. `telegram`) | | `output.peer` | yes | — | Peer/chat ID on output channel | #### Template Variables | Variable | Description | |----------|-------------| | `{{from}}` | Sender email address | | `{{to}}` | Recipient email address | | `{{subject}}` | Email subject line | | `{{snippet}}` | Gmail-provided snippet (first ~200 chars) | | `{{date}}` | Email date header | | `{{id}}` | Gmail message ID | | `{{labels}}` | Comma-separated label names | #### Troubleshooting **"Watch setup failed — Invalid topicName"** - Ensure `pubsub_topic` format is correct: `projects//topics/` - Verify the topic exists: `gcloud pubsub topics list` - Check IAM permissions (Gmail API must be able to publish) **"Permission denied" or "Forbidden"** - Grant publish permission to Gmail API: ```bash gcloud pubsub topics add-iam-policy-binding \ --member=serviceAccount:gmail-api-push@system.gserviceaccount.com \ --role=roles/pubsub.publisher ``` **Push notifications not arriving** - Verify endpoint is reachable: `curl -X POST https://your-domain.com/gmail/push` - Check subscription configuration: `gcloud pubsub subscriptions describe ` - Enable Tailscale funnel if using `tailscale_only: true` - Fall back to pull or polling mode **Check status**: ```bash flynn doctor # Validates config and reports active modes (push/pull/poll) ``` ``` #### 4.2 config/default.yaml **File**: `config/default.yaml` Update Gmail example (lines 128-136): ```yaml # gmail: # enabled: false # credentials_file: ~/.config/flynn/gmail-credentials.json # token_file: ~/.config/flynn/gmail-token.json # # # Push notifications (requires public endpoint or Tailscale funnel) # pubsub_topic: projects//topics/gmail-push # # pubsub_topic: gmail-push # Shorthand when project_id is in credentials # # # Pull subscription (works with any network setup) # # pubsub_subscription_id: projects//subscriptions/gmail-pull-sub # # pubsub_pull_interval: "60s" # # pubsub_max_messages: 10 # # # Disable push even if pubsub_topic is set (for debugging) # # disable_push: false # # watch_labels: [INBOX] # poll_interval: "60s" # History API fallback interval # message: "New email from {{from}}: {{subject}}\n\n{{snippet}}" # output: # channel: telegram # peer: "123456789" ``` --- ### 5. Dependencies **File**: `package.json` Add `@google-cloud/pubsub` for pull subscription support: ```json { "dependencies": { "@google-cloud/pubsub": "^4.0.0" } } ``` --- ## Test Plan ### 5.1 Unit Tests **File**: `src/automation/gmail.test.ts` Add tests for new functionality: ```typescript describe('GmailWatcher - Pub/Sub Pull', () => { it('should set up pull subscription with full subscription path', async () => { const config = { ...baseConfig, pubsub_subscription_id: 'projects/test-project/subscriptions/gmail-pull', pubsub_pull_interval: '30s', }; const watcher = new GmailWatcher(config, channelLookup); await watcher.connect(); // Verify subscriptionPath is set expect(watcher['subscriptionPath']).toBe('projects/test-project/subscriptions/gmail-pull'); expect(watcher['pullSubscriptionTimer']).toBeDefined(); await watcher.disconnect(); expect(watcher['pullSubscriptionTimer']).toBeUndefined(); }); it('should auto-prefix subscription with project_id when shorthand is used', async () => { const config = { ...baseConfig, pubsub_subscription_id: 'gmail-pull', }; const watcher = new GmailWatcher(config, channelLookup); await watcher.connect(); expect(watcher['subscriptionPath']).toBe('projects/test-project/subscriptions/gmail-pull'); await watcher.disconnect(); }); it('should skip pull setup when pubsub_subscription_id is not set', async () => { const watcher = new GmailWatcher(baseConfig, channelLookup); await watcher.connect(); expect(watcher['subscriptionPath']).toBeUndefined(); expect(watcher['pullSubscriptionTimer']).toBeUndefined(); await watcher.disconnect(); }); it('should warn on invalid subscription path format', async () => { const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const config = { ...baseConfig, pubsub_subscription_id: 'invalid-format', credentials_file: '~/.config/flynn/gmail-credentials-no-project.json', }; // Mock credentials without project_id vi.mocked(readFileSync).mockImplementation((path: string) => { if (path.includes('credentials')) { return JSON.stringify({ installed: { client_id: 'test-client-id', client_secret: 'test-client-secret', // No project_id }, }); } return JSON.stringify({ access_token: 'token' }); }); const watcher = new GmailWatcher(config, channelLookup); await watcher.connect(); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('pubsub_subscription_id \'invalid-format\' must be fully qualified') ); await watcher.disconnect(); consoleSpy.mockRestore(); }); }); describe('GmailWatcher - disable_push', () => { it('should skip watch setup when disable_push is true', async () => { const config = { ...baseConfig, pubsub_topic: 'projects/test-project/topics/gmail-push', disable_push: true, }; const watcher = new GmailWatcher(config, channelLookup); await watcher.connect(); // Watch should not be called const mockWatch = vi.mocked(google.gmail().users.watch); expect(mockWatch).not.toHaveBeenCalled(); await watcher.disconnect(); }); it('should log push disabled message when disable_push is true', async () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); const config = { ...baseConfig, pubsub_topic: 'gmail-push', disable_push: true, }; const watcher = new GmailWatcher(config, channelLookup); await watcher.connect(); expect(consoleSpy).toHaveBeenCalledWith( 'GmailWatcher: Push notifications disabled (disable_push: true)' ); await watcher.disconnect(); consoleSpy.mockRestore(); }); }); describe('GmailWatcher - buildWatchErrorHint', () => { it('should return topic hint for Invalid topicName error', () => { const watcher = new GmailWatcher(baseConfig, channelLookup); const hint = watcher['buildWatchErrorHint']('Invalid topicName'); expect(hint).toContain('Set automation.gmail.pubsub_topic'); expect(hint).toContain('projects/test-project/topics/gmail-push'); }); it('should return IAM hint for Permission denied error', () => { const watcher = new GmailWatcher(baseConfig, channelLookup); const hint = watcher['buildWatchErrorHint']('Permission denied'); expect(hint).toContain('gcloud pubsub topics add-iam-policy-binding'); expect(hint).toContain('gmail-api-push@system.gserviceaccount.com'); }); it('should return creation hint for Not found error', () => { const watcher = new GmailWatcher(baseConfig, channelLookup); const hint = watcher['buildWatchErrorHint']('Not found'); expect(hint).toContain('gcloud pubsub topics create'); }); }); describe('GmailWatcher - connection logging', () => { it('should log all active modes on connect', async () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); const config = { ...baseConfig, pubsub_topic: 'gmail-push', pubsub_subscription_id: 'gmail-pull', }; const watcher = new GmailWatcher(config, channelLookup); await watcher.connect(); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('push=true, pull=true, poll_interval=') ); await watcher.disconnect(); consoleSpy.mockRestore(); }); }); ``` ### 5.2 Doctor Check Tests **File**: `src/cli/doctor.test.ts` Add tests for enhanced Gmail validation: ```typescript describe('doctor - checkGmail enhanced', () => { it('should detect push mode', async () => { const config = { ...baseConfig, automation: { gmail: { enabled: true, credentials_file: '/mock/credentials.json', token_file: '/mock/token.json', pubsub_topic: 'projects/test/topics/gmail-push', output: { channel: 'telegram', peer: '123' }, }, }, }; const result = await checkGmail({ config }); expect(result.status).toBe('pass'); expect(result.detail).toContain('push + poll-fallback'); }); it('should detect pull mode', async () => { const config = { ...baseConfig, automation: { gmail: { enabled: true, credentials_file: '/mock/credentials.json', token_file: '/mock/token.json', pubsub_subscription_id: 'projects/test/subscriptions/gmail-pull', output: { channel: 'telegram', peer: '123' }, }, }, }; const result = await checkGmail({ config }); expect(result.status).toBe('pass'); expect(result.detail).toContain('pull + poll-fallback'); }); it('should detect hybrid mode', async () => { const config = { ...baseConfig, automation: { gmail: { enabled: true, credentials_file: '/mock/credentials.json', token_file: '/mock/token.json', pubsub_topic: 'gmail-push', pubsub_subscription_id: 'gmail-pull', output: { channel: 'telegram', peer: '123' }, }, }, }; const result = await checkGmail({ config }); expect(result.status).toBe('pass'); expect(result.detail).toContain('push + pull + poll-fallback'); }); it('should warn when push requires public endpoint but tailscale_only is enabled', async () => { const config = { ...baseConfig, server: { tailscale_only: true, }, automation: { gmail: { enabled: true, credentials_file: '/mock/credentials.json', token_file: '/mock/token.json', pubsub_topic: 'gmail-push', output: { channel: 'telegram', peer: '123' }, }, }, }; const result = await checkGmail({ config }); expect(result.status).toBe('warn'); expect(result.detail).toContain('push requires public endpoint or Tailscale funnel'); }); it('should pass when tailscale funnel is enabled', async () => { const config = { ...baseConfig, server: { tailscale_only: true, tailscale: { funnel: true }, }, automation: { gmail: { enabled: true, credentials_file: '/mock/credentials.json', token_file: '/mock/token.json', pubsub_topic: 'gmail-push', output: { channel: 'telegram', peer: '123' }, }, }, }; const result = await checkGmail({ config }); expect(result.status).toBe('pass'); expect(result.detail).not.toContain('⚠️'); }); }); ``` ### 5.3 Integration Tests Manual testing checklist: - [ ] **Push only**: Set `pubsub_topic`, start daemon, send test email, verify POST arrives at `/gmail/push`, verify agent response - [ ] **Pull only**: Set `pubsub_subscription_id`, start daemon, send test email, verify pull retrieves message, verify agent response - [ ] **Hybrid**: Set both, start daemon, send test email, verify either push or pull handles it (no duplicate processing) - [ ] **Polling only**: Omit both, start daemon, send test email, verify History API poll finds it - [ ] **Disable push**: Set `disable_push: true` with `pubsub_topic`, verify watch is not called - [ ] **Doctor check**: Run `flynn doctor` with each mode, verify output shows correct modes - [ ] **Error messages**: Trigger watch failure (invalid topic), verify hint contains actionable guidance - [ ] **Tailscale warning**: Set `tailscale_only: true` without funnel, verify doctor warns about push --- ## Files to Modify ### Core Implementation | File | Changes | |------|---------| | `src/config/schema.ts` | Add `pubsub_subscription_id`, `pubsub_pull_interval`, `pubsub_max_messages`, `disable_push` fields | | `src/automation/gmail.ts` | Add `setupPullSubscription()`, `pullSubscriptionMessages()`, `buildWatchErrorHint()`, update `connect()` / `disconnect()` | | `src/cli/doctor.ts` | Enhance `checkGmail` with Pub/Sub mode detection and Tailscale reachability check | | `package.json` | Add `@google-cloud/pubsub` dependency | ### Documentation | File | Changes | |------|---------| | `README.md` | Rewrite Gmail section with deployment patterns, setup instructions, troubleshooting | | `config/default.yaml` | Update Gmail example with new fields and comments | | `docs/plans/state.json` | Mark `gmail-push-revisit` as completed | ### Tests | File | Changes | |------|---------| | `src/automation/gmail.test.ts` | Add tests for pull mode, `disable_push`, error hints, connection logging | | `src/cli/doctor.test.ts` | Add tests for enhanced Gmail validation (mode detection, Tailscale warning) | --- ## Summary This implementation plan makes Gmail notifications operationally reliable by: 1. **Supporting three deployment patterns**: Push (public endpoint), Pull (private), Hybrid (both) 2. **Clarifying config surface**: Explicit `pubsub_subscription_id`, `pubsub_pull_interval`, `disable_push` fields 3. **Improving error messages**: Actionable hints for common setup failures (invalid topic, missing IAM, etc.) 4. **Enhancing diagnostics**: `flynn doctor` reports active modes and warns about reachability issues 5. **Comprehensive documentation**: Setup guides for each pattern, GCP resource instructions, troubleshooting **Recommended default**: Hybrid mode (push + pull + polling) for maximum reliability across all network configurations. **Estimated effort**: 1-2 days (implementation + testing + docs) **No breaking changes**: Existing `pubsub_topic`-only configs continue to work (push + polling)