@conversionpros/aiva 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/README.md +148 -0
  2. package/auto-deploy.js +190 -0
  3. package/bin/aiva.js +81 -0
  4. package/cli-sync.js +126 -0
  5. package/d2a-prompt-template.txt +106 -0
  6. package/diagnostics-api.js +304 -0
  7. package/docs/ara-dedup-fix-scope.md +112 -0
  8. package/docs/ara-fix-round2-scope.md +61 -0
  9. package/docs/ara-greeting-fix-scope.md +70 -0
  10. package/docs/calendar-date-fix-scope.md +28 -0
  11. package/docs/getting-started.md +115 -0
  12. package/docs/network-architecture-rollout-scope.md +43 -0
  13. package/docs/scope-google-oauth-integration.md +351 -0
  14. package/docs/settings-page-scope.md +50 -0
  15. package/docs/xai-imagine-scope.md +116 -0
  16. package/docs/xai-voice-integration-scope.md +115 -0
  17. package/docs/xai-voice-tools-scope.md +165 -0
  18. package/email-router.js +512 -0
  19. package/follow-up-handler.js +606 -0
  20. package/gateway-monitor.js +158 -0
  21. package/google-email.js +379 -0
  22. package/google-oauth.js +310 -0
  23. package/grok-imagine.js +97 -0
  24. package/health-reporter.js +287 -0
  25. package/invisible-prefix-base.txt +206 -0
  26. package/invisible-prefix-owner.txt +26 -0
  27. package/invisible-prefix-slim.txt +10 -0
  28. package/invisible-prefix.txt +43 -0
  29. package/knowledge-base.js +472 -0
  30. package/lib/cli.js +19 -0
  31. package/lib/config.js +124 -0
  32. package/lib/health.js +57 -0
  33. package/lib/process.js +207 -0
  34. package/lib/server.js +42 -0
  35. package/lib/setup.js +472 -0
  36. package/meta-capi.js +206 -0
  37. package/meta-leads.js +411 -0
  38. package/notion-oauth.js +323 -0
  39. package/package.json +61 -0
  40. package/public/agent-config.html +241 -0
  41. package/public/aiva-avatar-anime.png +0 -0
  42. package/public/css/docs.css.bak +688 -0
  43. package/public/css/onboarding.css +543 -0
  44. package/public/diagrams/claude-subscription-pool.html +329 -0
  45. package/public/diagrams/claude-subscription-pool.png +0 -0
  46. package/public/docs-icon.png +0 -0
  47. package/public/escalation.html +237 -0
  48. package/public/group-config.html +300 -0
  49. package/public/icon-192.png +0 -0
  50. package/public/icon-512.png +0 -0
  51. package/public/icons/agents.svg +1 -0
  52. package/public/icons/attach.svg +1 -0
  53. package/public/icons/characters.svg +1 -0
  54. package/public/icons/chat.svg +1 -0
  55. package/public/icons/docs.svg +1 -0
  56. package/public/icons/heartbeat.svg +1 -0
  57. package/public/icons/messages.svg +1 -0
  58. package/public/icons/mic.svg +1 -0
  59. package/public/icons/notes.svg +1 -0
  60. package/public/icons/settings.svg +1 -0
  61. package/public/icons/tasks.svg +1 -0
  62. package/public/images/onboarding/p0-communication-layer.png +0 -0
  63. package/public/images/onboarding/p0-infinite-surface.png +0 -0
  64. package/public/images/onboarding/p0-learning-model.png +0 -0
  65. package/public/images/onboarding/p0-meet-aiva.png +0 -0
  66. package/public/images/onboarding/p4-contact-intelligence.png +0 -0
  67. package/public/images/onboarding/p4-context-compounds.png +0 -0
  68. package/public/images/onboarding/p4-message-router.png +0 -0
  69. package/public/images/onboarding/p4-per-contact-rules.png +0 -0
  70. package/public/images/onboarding/p4-send-messages.png +0 -0
  71. package/public/images/onboarding/p6-be-precise.png +0 -0
  72. package/public/images/onboarding/p6-review-escalations.png +0 -0
  73. package/public/images/onboarding/p6-voice-input.png +0 -0
  74. package/public/images/onboarding/p7-completion.png +0 -0
  75. package/public/index.html +11594 -0
  76. package/public/js/onboarding.js +699 -0
  77. package/public/manifest.json +24 -0
  78. package/public/messages-v2.html +2824 -0
  79. package/public/permission-approve.html.bak +107 -0
  80. package/public/permissions.html +150 -0
  81. package/public/styles/design-system.css +68 -0
  82. package/router-db.js +604 -0
  83. package/router-utils.js +28 -0
  84. package/router-v2/adapters/imessage.js +191 -0
  85. package/router-v2/adapters/quo.js +82 -0
  86. package/router-v2/adapters/whatsapp.js +192 -0
  87. package/router-v2/contact-manager.js +234 -0
  88. package/router-v2/conversation-engine.js +498 -0
  89. package/router-v2/data/knowledge-base.json +176 -0
  90. package/router-v2/data/router-v2.db +0 -0
  91. package/router-v2/data/router-v2.db-shm +0 -0
  92. package/router-v2/data/router-v2.db-wal +0 -0
  93. package/router-v2/data/router.db +0 -0
  94. package/router-v2/db.js +457 -0
  95. package/router-v2/escalation-bridge.js +540 -0
  96. package/router-v2/follow-up-engine.js +347 -0
  97. package/router-v2/index.js +441 -0
  98. package/router-v2/ingestion.js +213 -0
  99. package/router-v2/knowledge-base.js +231 -0
  100. package/router-v2/lead-qualifier.js +152 -0
  101. package/router-v2/learning-loop.js +202 -0
  102. package/router-v2/outbound-sender.js +160 -0
  103. package/router-v2/package.json +13 -0
  104. package/router-v2/permission-gate.js +86 -0
  105. package/router-v2/playbook.js +177 -0
  106. package/router-v2/prompts/base.js +52 -0
  107. package/router-v2/prompts/first-contact.js +38 -0
  108. package/router-v2/prompts/lead-qualification.js +37 -0
  109. package/router-v2/prompts/scheduling.js +72 -0
  110. package/router-v2/prompts/style-overrides.js +22 -0
  111. package/router-v2/scheduler.js +301 -0
  112. package/router-v2/scripts/migrate-v1-to-v2.js +215 -0
  113. package/router-v2/scripts/seed-faq.js +67 -0
  114. package/router-v2/seed-knowledge-base.js +39 -0
  115. package/router-v2/utils/ai.js +129 -0
  116. package/router-v2/utils/phone.js +52 -0
  117. package/router-v2/utils/response-validator.js +98 -0
  118. package/router-v2/utils/sanitize.js +222 -0
  119. package/router.js +5005 -0
  120. package/routes/google-calendar.js +186 -0
  121. package/scripts/deploy.sh +62 -0
  122. package/scripts/macos-calendar.sh +232 -0
  123. package/scripts/onboard-device.sh +466 -0
  124. package/server.js +5131 -0
  125. package/start.sh +24 -0
  126. package/templates/AGENTS.md +548 -0
  127. package/templates/IDENTITY.md +15 -0
  128. package/templates/docs-agents.html +132 -0
  129. package/templates/docs-app.html +130 -0
  130. package/templates/docs-home.html +83 -0
  131. package/templates/docs-imessage.html +121 -0
  132. package/templates/docs-tasks.html +123 -0
  133. package/templates/docs-tips.html +175 -0
  134. package/templates/getting-started.html +809 -0
  135. package/templates/invisible-prefix-base.txt +171 -0
  136. package/templates/invisible-prefix-owner.txt +282 -0
  137. package/templates/invisible-prefix.txt +338 -0
  138. package/templates/manifest.json +61 -0
  139. package/templates/memory-org/clients.md +7 -0
  140. package/templates/memory-org/credentials.md +9 -0
  141. package/templates/memory-org/devices.md +7 -0
  142. package/templates/updates.html +464 -0
  143. package/templates/workspace/AGENTS.md.tmpl +161 -0
  144. package/templates/workspace/HEARTBEAT.md.tmpl +17 -0
  145. package/templates/workspace/IDENTITY.md.tmpl +15 -0
  146. package/templates/workspace/MEMORY.md.tmpl +16 -0
  147. package/templates/workspace/SOUL.md.tmpl +51 -0
  148. package/templates/workspace/USER.md.tmpl +25 -0
  149. package/tts-proxy.js +96 -0
  150. package/voice-call-local.js +731 -0
  151. package/voice-call.js +732 -0
  152. package/wa-listener.js +354 -0
@@ -0,0 +1,158 @@
1
+ /**
2
+ * AIVA Gateway Health Monitor
3
+ * Checks gateway health every 60s with 3-tier recovery escalation.
4
+ * Alerts Brandon via AIVA chat API (primary) and iMessage (fallback).
5
+ * Cooldown: max 1 recovery attempt per 5 minutes.
6
+ */
7
+
8
+ const http = require('http');
9
+ const { exec, execSync } = require('child_process');
10
+
11
+ const GATEWAY_URL = 'http://localhost:18789';
12
+ const AIVA_CHAT_URL = 'http://localhost:3847/api/chat/aiva-reply';
13
+ const BRANDON_PHONE = '+15099794110';
14
+ const CHECK_INTERVAL_MS = 60 * 1000;
15
+ const COOLDOWN_MS = 5 * 60 * 1000;
16
+ const RECHECK_DELAY_MS = 15 * 1000;
17
+
18
+ let lastRecoveryAttempt = 0;
19
+
20
+ function log(msg) {
21
+ console.log(`[gateway-monitor] ${new Date().toISOString()} ${msg}`);
22
+ }
23
+
24
+ function probeGateway() {
25
+ return new Promise((resolve) => {
26
+ const req = http.get(GATEWAY_URL, { timeout: 5000 }, (res) => {
27
+ res.resume();
28
+ resolve(true);
29
+ });
30
+ req.on('error', () => resolve(false));
31
+ req.on('timeout', () => { req.destroy(); resolve(false); });
32
+ });
33
+ }
34
+
35
+ function runCommand(cmd) {
36
+ return new Promise((resolve) => {
37
+ exec(cmd, { timeout: 30000 }, (err, stdout, stderr) => {
38
+ resolve({ err, stdout, stderr });
39
+ });
40
+ });
41
+ }
42
+
43
+ function sleep(ms) {
44
+ return new Promise((resolve) => setTimeout(resolve, ms));
45
+ }
46
+
47
+ /**
48
+ * Send alert to Brandon via AIVA chat API (primary) with iMessage fallback.
49
+ */
50
+ async function alertBrandon(message) {
51
+ const timestamp = new Date().toLocaleString('en-US', { timeZone: 'America/Los_Angeles' });
52
+ const fullMessage = `${message}\n🕐 ${timestamp}`;
53
+
54
+ // Primary: AIVA chat API
55
+ let aivaSent = false;
56
+ try {
57
+ aivaSent = await new Promise((resolve) => {
58
+ const postData = JSON.stringify({ userId: 'brandon', text: fullMessage });
59
+ const req = http.request(AIVA_CHAT_URL, {
60
+ method: 'POST',
61
+ headers: { 'Content-Type': 'application/json', 'x-aiva-internal': 'true' },
62
+ timeout: 5000,
63
+ }, (res) => {
64
+ res.resume();
65
+ resolve(res.statusCode >= 200 && res.statusCode < 300);
66
+ });
67
+ req.on('error', () => resolve(false));
68
+ req.on('timeout', () => { req.destroy(); resolve(false); });
69
+ req.write(postData);
70
+ req.end();
71
+ });
72
+ if (aivaSent) {
73
+ log(`Alert sent via AIVA chat: ${message}`);
74
+ }
75
+ } catch (e) {
76
+ log(`AIVA chat alert failed: ${e.message}`);
77
+ }
78
+
79
+ // Fallback: iMessage via imsg CLI if AIVA failed
80
+ if (!aivaSent) {
81
+ try {
82
+ execSync(`/opt/homebrew/bin/imsg send "${BRANDON_PHONE}" "${fullMessage.replace(/"/g, '\\"')}"`, { timeout: 10000 });
83
+ log(`Alert sent via iMessage fallback: ${message}`);
84
+ } catch (e) {
85
+ log(`CRITICAL: Both AIVA and iMessage alerts failed! Message was: ${message}`);
86
+ }
87
+ }
88
+ }
89
+
90
+ async function recover() {
91
+ const now = Date.now();
92
+ if (now - lastRecoveryAttempt < COOLDOWN_MS) {
93
+ log('Cooldown active — skipping recovery');
94
+ return;
95
+ }
96
+ lastRecoveryAttempt = now;
97
+
98
+ await alertBrandon('🚨 AIVA Gateway DOWN — starting automatic recovery...');
99
+
100
+ // Tier 1: Simple restart
101
+ log('TIER 1: Gateway unreachable — running openclaw gateway start');
102
+ await runCommand('/opt/homebrew/bin/openclaw gateway start');
103
+ await sleep(RECHECK_DELAY_MS);
104
+ if (await probeGateway()) {
105
+ log('TIER 1: Recovery successful — gateway is healthy');
106
+ await alertBrandon('✅ AIVA Gateway RECOVERED via simple restart (Tier 1)');
107
+ return;
108
+ }
109
+
110
+ // Tier 2: Doctor repair
111
+ log('TIER 2: Still unreachable — running openclaw doctor --repair');
112
+ await alertBrandon('⚠️ Simple restart failed. Trying doctor --repair (Tier 2)...');
113
+ await runCommand('/opt/homebrew/bin/openclaw doctor --repair');
114
+ await runCommand('/opt/homebrew/bin/openclaw gateway start');
115
+ await sleep(RECHECK_DELAY_MS);
116
+ if (await probeGateway()) {
117
+ log('TIER 2: Recovery successful — gateway is healthy');
118
+ await alertBrandon('✅ AIVA Gateway RECOVERED via doctor --repair (Tier 2)');
119
+ return;
120
+ }
121
+
122
+ // Tier 3: Claude Code escalation
123
+ log('TIER 3: Escalating to Claude Code for diagnosis');
124
+ await alertBrandon('🔴 GATEWAY STILL DOWN after Tier 1 & 2. Escalating to Claude Code (Tier 3)...');
125
+ const claudeCmd = `claude -p "The AIVA gateway on this machine failed to start. Run 'openclaw gateway start' and 'openclaw doctor --repair' to diagnose. Check logs at ~/.openclaw/logs/. Fix whatever is wrong and get the gateway running." --allowedTools bash,computer`;
126
+ exec(claudeCmd, { timeout: 300000 }, async (err, stdout, stderr) => {
127
+ // Check if gateway came back after Claude Code
128
+ await sleep(30000);
129
+ if (await probeGateway()) {
130
+ await alertBrandon('✅ AIVA Gateway RECOVERED via Claude Code escalation (Tier 3)');
131
+ } else {
132
+ await alertBrandon('🚨🚨 CRITICAL: AIVA Gateway STILL DOWN after all recovery tiers. NEEDS MANUAL INTERVENTION! 🚨🚨');
133
+ }
134
+ });
135
+ }
136
+
137
+ async function check() {
138
+ try {
139
+ const healthy = await probeGateway();
140
+ if (!healthy) {
141
+ log('Gateway health check FAILED — starting recovery');
142
+ await recover();
143
+ }
144
+ } catch (err) {
145
+ log(`Monitor error (continuing): ${err.message}`);
146
+ }
147
+ }
148
+
149
+ function start() {
150
+ log('Started — checking gateway every 60s');
151
+ // Initial check after 10s (let app finish starting)
152
+ setTimeout(() => {
153
+ check();
154
+ setInterval(check, CHECK_INTERVAL_MS);
155
+ }, 10000);
156
+ }
157
+
158
+ module.exports = { start };
@@ -0,0 +1,379 @@
1
+ /**
2
+ * Google Email (Gmail) API Endpoints - Phase 2
3
+ *
4
+ * Endpoints:
5
+ * GET /api/integrations/google/emails - List emails
6
+ * GET /api/integrations/google/emails/:id - Get single email
7
+ * POST /api/integrations/google/emails/send - Send email
8
+ * POST /api/integrations/google/emails/:id/reply - Reply to email
9
+ * DELETE /api/integrations/google/emails/:id - Trash email
10
+ * POST /api/integrations/google/emails/:id/labels - Modify labels
11
+ */
12
+
13
+ const express = require('express');
14
+ const router = express.Router();
15
+ const { getValidAccessToken, loadTokens } = require('./google-oauth');
16
+
17
+ const GMAIL_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me';
18
+
19
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
20
+
21
+ function resolveAccountId(query, body) {
22
+ const accountId = (body && body.accountId) || (query && query.accountId);
23
+ if (accountId) return accountId;
24
+ // If no accountId, try to use first account
25
+ const store = loadTokens();
26
+ if (store.google.accounts.length === 1) return store.google.accounts[0].id;
27
+ return null;
28
+ }
29
+
30
+ async function gmailFetch(token, endpoint, options = {}) {
31
+ const url = endpoint.startsWith('http') ? endpoint : `${GMAIL_BASE}${endpoint}`;
32
+ const resp = await fetch(url, {
33
+ ...options,
34
+ headers: {
35
+ Authorization: `Bearer ${token}`,
36
+ 'Content-Type': 'application/json',
37
+ ...(options.headers || {}),
38
+ },
39
+ });
40
+ if (!resp.ok) {
41
+ const errText = await resp.text();
42
+ const err = new Error(`Gmail API error ${resp.status}`);
43
+ err.status = resp.status;
44
+ err.details = errText;
45
+ throw err;
46
+ }
47
+ return resp.json();
48
+ }
49
+
50
+ function parseEmailHeaders(headers) {
51
+ const get = (name) => {
52
+ const h = headers.find(h => h.name.toLowerCase() === name.toLowerCase());
53
+ return h ? h.value : null;
54
+ };
55
+ return { from: get('From'), to: get('To'), cc: get('Cc'), bcc: get('Bcc'), subject: get('Subject'), date: get('Date'), messageId: get('Message-ID'), inReplyTo: get('In-Reply-To'), references: get('References') };
56
+ }
57
+
58
+ function extractBody(payload) {
59
+ if (!payload) return { text: null, html: null };
60
+
61
+ // Simple single-part message
62
+ if (payload.body && payload.body.size > 0 && payload.body.data) {
63
+ const decoded = Buffer.from(payload.body.data, 'base64url').toString('utf8');
64
+ if (payload.mimeType === 'text/html') return { text: null, html: decoded };
65
+ return { text: decoded, html: null };
66
+ }
67
+
68
+ // Multipart
69
+ let text = null, html = null;
70
+ function walk(parts) {
71
+ if (!parts) return;
72
+ for (const part of parts) {
73
+ if (part.mimeType === 'text/plain' && part.body && part.body.data) {
74
+ text = Buffer.from(part.body.data, 'base64url').toString('utf8');
75
+ } else if (part.mimeType === 'text/html' && part.body && part.body.data) {
76
+ html = Buffer.from(part.body.data, 'base64url').toString('utf8');
77
+ } else if (part.parts) {
78
+ walk(part.parts);
79
+ }
80
+ }
81
+ }
82
+ walk(payload.parts);
83
+ return { text, html };
84
+ }
85
+
86
+ function extractAttachments(payload) {
87
+ const attachments = [];
88
+ function walk(parts) {
89
+ if (!parts) return;
90
+ for (const part of parts) {
91
+ if (part.filename && part.filename.length > 0) {
92
+ attachments.push({
93
+ partId: part.partId,
94
+ filename: part.filename,
95
+ mimeType: part.mimeType,
96
+ size: part.body ? part.body.size : 0,
97
+ attachmentId: part.body ? part.body.attachmentId : null,
98
+ });
99
+ }
100
+ if (part.parts) walk(part.parts);
101
+ }
102
+ }
103
+ walk(payload.parts);
104
+ return attachments;
105
+ }
106
+
107
+ function buildRawEmail({ from, to, cc, bcc, subject, body, inReplyTo, references, threadId }) {
108
+ const lines = [];
109
+ lines.push(`From: ${from}`);
110
+ lines.push(`To: ${Array.isArray(to) ? to.join(', ') : to}`);
111
+ if (cc) lines.push(`Cc: ${Array.isArray(cc) ? cc.join(', ') : cc}`);
112
+ if (bcc) lines.push(`Bcc: ${Array.isArray(bcc) ? bcc.join(', ') : bcc}`);
113
+ lines.push(`Subject: ${subject}`);
114
+ if (inReplyTo) lines.push(`In-Reply-To: ${inReplyTo}`);
115
+ if (references) lines.push(`References: ${references}`);
116
+ lines.push('MIME-Version: 1.0');
117
+ lines.push('Content-Type: text/html; charset=UTF-8');
118
+ lines.push('');
119
+ lines.push(body);
120
+
121
+ const raw = Buffer.from(lines.join('\r\n')).toString('base64url');
122
+ return raw;
123
+ }
124
+
125
+ function wrapResponse(data) {
126
+ return { success: true, data, error: null };
127
+ }
128
+
129
+ function wrapError(message, extra = {}) {
130
+ return { success: false, data: null, error: message, ...extra };
131
+ }
132
+
133
+ // ─── Middleware to resolve account ─────────────────────────────────────────────
134
+
135
+ async function withAccount(req, res, next) {
136
+ const accountId = resolveAccountId(req.query, req.body);
137
+ if (!accountId) {
138
+ return res.status(400).json(wrapError('accountId is required (query or body param). Multiple accounts linked.'));
139
+ }
140
+ try {
141
+ const { token, email } = await getValidAccessToken(accountId);
142
+ req.gmailToken = token;
143
+ req.gmailEmail = email;
144
+ req.gmailAccountId = accountId;
145
+ next();
146
+ } catch (e) {
147
+ if (e.message === 'GOOGLE_AUTH_EXPIRED') {
148
+ return res.status(401).json(wrapError('GOOGLE_AUTH_EXPIRED', { message: 'Google account requires re-authentication', accountId }));
149
+ }
150
+ if (e.message === 'Account not found') {
151
+ return res.status(404).json(wrapError('Account not found', { accountId }));
152
+ }
153
+ console.error('[google-email] Auth error:', e.message);
154
+ return res.status(500).json(wrapError(e.message));
155
+ }
156
+ }
157
+
158
+ // ─── Routes ───────────────────────────────────────────────────────────────────
159
+
160
+ // 1. List emails
161
+ router.get('/', withAccount, async (req, res) => {
162
+ try {
163
+ const { maxResults = '20', pageToken, q, labelIds } = req.query;
164
+ const params = new URLSearchParams({ maxResults });
165
+ if (pageToken) params.set('pageToken', pageToken);
166
+ if (q) params.set('q', q);
167
+ if (labelIds) {
168
+ const labels = Array.isArray(labelIds) ? labelIds : labelIds.split(',');
169
+ labels.forEach(l => params.append('labelIds', l.trim()));
170
+ } else {
171
+ params.append('labelIds', 'INBOX');
172
+ }
173
+
174
+ const listData = await gmailFetch(req.gmailToken, `/messages?${params}`);
175
+
176
+ if (!listData.messages || listData.messages.length === 0) {
177
+ return res.json(wrapResponse({ messages: [], nextPageToken: null, resultSizeEstimate: 0 }));
178
+ }
179
+
180
+ // Batch fetch metadata for each message
181
+ const messages = await Promise.all(
182
+ listData.messages.map(async (m) => {
183
+ const msg = await gmailFetch(req.gmailToken, `/messages/${m.id}?format=metadata&metadataHeaders=From&metadataHeaders=To&metadataHeaders=Subject&metadataHeaders=Date`);
184
+ const headers = parseEmailHeaders(msg.payload ? msg.payload.headers : []);
185
+ return {
186
+ id: msg.id,
187
+ threadId: msg.threadId,
188
+ snippet: msg.snippet,
189
+ labelIds: msg.labelIds,
190
+ isUnread: (msg.labelIds || []).includes('UNREAD'),
191
+ from: headers.from,
192
+ to: headers.to,
193
+ subject: headers.subject,
194
+ date: headers.date,
195
+ internalDate: msg.internalDate,
196
+ };
197
+ })
198
+ );
199
+
200
+ res.json(wrapResponse({
201
+ messages,
202
+ nextPageToken: listData.nextPageToken || null,
203
+ resultSizeEstimate: listData.resultSizeEstimate || messages.length,
204
+ }));
205
+ } catch (e) {
206
+ console.error('[google-email] List error:', e.message);
207
+ res.status(e.status || 500).json(wrapError(e.message, { details: e.details }));
208
+ }
209
+ });
210
+
211
+ // 2. Draft operations (MUST be before /:id to avoid route conflict)
212
+ router.post('/drafts', withAccount, async (req, res) => {
213
+ try {
214
+ const { to, cc, bcc, subject, body, from } = req.body;
215
+ if (!to || !subject || !body) {
216
+ return res.status(400).json(wrapError('to, subject, and body are required'));
217
+ }
218
+
219
+ const raw = buildRawEmail({ from: from || req.gmailEmail, to, cc, bcc, subject, body });
220
+ const result = await gmailFetch(req.gmailToken, '/drafts', {
221
+ method: 'POST',
222
+ body: JSON.stringify({ message: { raw } }),
223
+ });
224
+
225
+ res.json(wrapResponse({ draftId: result.id, messageId: result.message?.id, threadId: result.message?.threadId }));
226
+ } catch (e) {
227
+ console.error('[google-email] Draft create error:', e.message);
228
+ res.status(e.status || 500).json(wrapError(e.message, { details: e.details }));
229
+ }
230
+ });
231
+
232
+ router.get('/drafts', withAccount, async (req, res) => {
233
+ try {
234
+ const maxResults = req.query.maxResults || 10;
235
+ const result = await gmailFetch(req.gmailToken, `/drafts?maxResults=${maxResults}`);
236
+ res.json(wrapResponse(result.drafts || []));
237
+ } catch (e) {
238
+ console.error('[google-email] Draft list error:', e.message);
239
+ res.status(e.status || 500).json(wrapError(e.message));
240
+ }
241
+ });
242
+
243
+ router.delete('/drafts/:draftId', withAccount, async (req, res) => {
244
+ try {
245
+ const resp = await fetch(`https://gmail.googleapis.com/gmail/v1/users/me/drafts/${req.params.draftId}`, {
246
+ method: 'DELETE',
247
+ headers: { 'Authorization': `Bearer ${req.gmailToken}` },
248
+ });
249
+ if (!resp.ok && resp.status !== 204) {
250
+ const errText = await resp.text().catch(() => '');
251
+ throw new Error(`Gmail API error ${resp.status}: ${errText}`);
252
+ }
253
+ res.json(wrapResponse({ deleted: true, draftId: req.params.draftId }));
254
+ } catch (e) {
255
+ console.error('[google-email] Draft delete error:', e.message);
256
+ res.status(e.status || 500).json(wrapError(e.message));
257
+ }
258
+ });
259
+
260
+ // 3. Get single email
261
+ router.get('/:id', withAccount, async (req, res) => {
262
+ try {
263
+ const msg = await gmailFetch(req.gmailToken, `/messages/${req.params.id}?format=full`);
264
+ const headers = parseEmailHeaders(msg.payload ? msg.payload.headers : []);
265
+ const body = extractBody(msg.payload);
266
+ const attachments = extractAttachments(msg.payload);
267
+
268
+ res.json(wrapResponse({
269
+ id: msg.id,
270
+ threadId: msg.threadId,
271
+ snippet: msg.snippet,
272
+ labelIds: msg.labelIds,
273
+ isUnread: (msg.labelIds || []).includes('UNREAD'),
274
+ headers,
275
+ body,
276
+ attachments,
277
+ sizeEstimate: msg.sizeEstimate,
278
+ internalDate: msg.internalDate,
279
+ }));
280
+ } catch (e) {
281
+ console.error('[google-email] Get error:', e.message);
282
+ res.status(e.status || 500).json(wrapError(e.message, { details: e.details }));
283
+ }
284
+ });
285
+
286
+ // 3. Send email
287
+ router.post('/send', withAccount, async (req, res) => {
288
+ try {
289
+ const { to, cc, bcc, subject, body, threadId } = req.body;
290
+ if (!to || !subject || !body) {
291
+ return res.status(400).json(wrapError('to, subject, and body are required'));
292
+ }
293
+
294
+ const raw = buildRawEmail({ from: req.gmailEmail, to, cc, bcc, subject, body });
295
+ const payload = { raw };
296
+ if (threadId) payload.threadId = threadId;
297
+
298
+ const result = await gmailFetch(req.gmailToken, '/messages/send', {
299
+ method: 'POST',
300
+ body: JSON.stringify(payload),
301
+ });
302
+
303
+ res.json(wrapResponse({ id: result.id, threadId: result.threadId, labelIds: result.labelIds }));
304
+ } catch (e) {
305
+ console.error('[google-email] Send error:', e.message);
306
+ res.status(e.status || 500).json(wrapError(e.message, { details: e.details }));
307
+ }
308
+ });
309
+
310
+ // 5. Reply to email
311
+ router.post('/:id/reply', withAccount, async (req, res) => {
312
+ try {
313
+ const { body } = req.body;
314
+ if (!body) return res.status(400).json(wrapError('body is required'));
315
+
316
+ // Fetch original message for threading info
317
+ const original = await gmailFetch(req.gmailToken, `/messages/${req.params.id}?format=metadata&metadataHeaders=From&metadataHeaders=To&metadataHeaders=Subject&metadataHeaders=Message-ID&metadataHeaders=References`);
318
+ const origHeaders = parseEmailHeaders(original.payload ? original.payload.headers : []);
319
+
320
+ const replyTo = origHeaders.from;
321
+ const subject = origHeaders.subject && origHeaders.subject.startsWith('Re:') ? origHeaders.subject : `Re: ${origHeaders.subject || ''}`;
322
+ const references = [origHeaders.references, origHeaders.messageId].filter(Boolean).join(' ');
323
+
324
+ const raw = buildRawEmail({
325
+ from: req.gmailEmail,
326
+ to: replyTo,
327
+ subject,
328
+ body,
329
+ inReplyTo: origHeaders.messageId,
330
+ references,
331
+ });
332
+
333
+ const result = await gmailFetch(req.gmailToken, '/messages/send', {
334
+ method: 'POST',
335
+ body: JSON.stringify({ raw, threadId: original.threadId }),
336
+ });
337
+
338
+ res.json(wrapResponse({ id: result.id, threadId: result.threadId, labelIds: result.labelIds }));
339
+ } catch (e) {
340
+ console.error('[google-email] Reply error:', e.message);
341
+ res.status(e.status || 500).json(wrapError(e.message, { details: e.details }));
342
+ }
343
+ });
344
+
345
+ // 5. Trash email
346
+ router.delete('/:id', withAccount, async (req, res) => {
347
+ try {
348
+ await gmailFetch(req.gmailToken, `/messages/${req.params.id}/trash`, { method: 'POST' });
349
+ res.json(wrapResponse({ id: req.params.id, trashed: true }));
350
+ } catch (e) {
351
+ console.error('[google-email] Trash error:', e.message);
352
+ res.status(e.status || 500).json(wrapError(e.message, { details: e.details }));
353
+ }
354
+ });
355
+
356
+ // 6. Modify labels (read/unread, archive, etc.)
357
+ router.post('/:id/labels', withAccount, async (req, res) => {
358
+ try {
359
+ const { addLabelIds, removeLabelIds } = req.body;
360
+ if (!addLabelIds && !removeLabelIds) {
361
+ return res.status(400).json(wrapError('addLabelIds or removeLabelIds required'));
362
+ }
363
+
364
+ const result = await gmailFetch(req.gmailToken, `/messages/${req.params.id}/modify`, {
365
+ method: 'POST',
366
+ body: JSON.stringify({
367
+ addLabelIds: addLabelIds || [],
368
+ removeLabelIds: removeLabelIds || [],
369
+ }),
370
+ });
371
+
372
+ res.json(wrapResponse({ id: result.id, threadId: result.threadId, labelIds: result.labelIds }));
373
+ } catch (e) {
374
+ console.error('[google-email] Labels error:', e.message);
375
+ res.status(e.status || 500).json(wrapError(e.message, { details: e.details }));
376
+ }
377
+ });
378
+
379
+ module.exports = { router };