@realtimex/email-automator 2.10.9 → 2.11.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.
package/api/server.ts CHANGED
@@ -4,6 +4,9 @@ import path, { join } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { spawn } from 'child_process';
6
6
  import { existsSync } from 'fs';
7
+ import axios from 'axios';
8
+ import crypto from 'crypto';
9
+ import { SDKService } from './src/services/SDKService.js';
7
10
  import { config, validateConfig } from './src/config/index.js';
8
11
  import { errorHandler } from './src/middleware/errorHandler.js';
9
12
  import { apiRateLimit } from './src/middleware/rateLimit.js';
@@ -21,6 +24,9 @@ if (!configValidation.valid) {
21
24
  logger.warn('Configuration warnings', { errors: configValidation.errors });
22
25
  }
23
26
 
27
+ // Initialize RealTimeX SDK
28
+ SDKService.initialize();
29
+
24
30
  const app = express();
25
31
 
26
32
  // Security headers
@@ -36,8 +42,8 @@ app.use((req, res, next) => {
36
42
 
37
43
  // CORS configuration
38
44
  app.use(cors({
39
- origin: config.isProduction
40
- ? config.security.corsOrigins
45
+ origin: config.isProduction
46
+ ? config.security.corsOrigins
41
47
  : true, // Allow all in development
42
48
  credentials: true,
43
49
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
@@ -67,6 +73,189 @@ app.use('/api', apiRateLimit);
67
73
  // API routes
68
74
  app.use('/api', routes);
69
75
 
76
+ // --- SETUP & PROVISIONING (Golden Standard) ---
77
+
78
+ // GET /api/setup/organizations - List Supabase organizations using access token
79
+ app.get('/api/setup/organizations', async (req, res) => {
80
+ const authHeader = req.headers['authorization'];
81
+ if (!authHeader) {
82
+ return res.status(401).json({ error: 'Missing Authorization header' });
83
+ }
84
+
85
+ try {
86
+ const response = await axios.get('https://api.supabase.com/v1/organizations', {
87
+ headers: { 'Authorization': authHeader }
88
+ });
89
+ res.json(response.data);
90
+ } catch (error: any) {
91
+ logger.error('Failed to fetch organizations', error);
92
+ res.status(error.response?.status || 500).json({
93
+ error: error.response?.data?.message || 'Failed to fetch organizations'
94
+ });
95
+ }
96
+ });
97
+
98
+ // POST /api/setup/auto-provision - Create project and poll for readiness (SSE)
99
+ app.post('/api/setup/auto-provision', async (req, res) => {
100
+ const { orgId, projectName: customProjectName, region: customRegion } = req.body;
101
+ const authHeader = req.headers['authorization'];
102
+
103
+ if (!orgId) {
104
+ return res.status(400).json({ error: 'Missing required parameter (orgId)' });
105
+ }
106
+
107
+ if (!authHeader) {
108
+ return res.status(401).json({ error: 'Missing Authorization header' });
109
+ }
110
+
111
+ res.setHeader('Content-Type', 'text/event-stream');
112
+ res.setHeader('Cache-Control', 'no-cache');
113
+ res.setHeader('Connection', 'keep-alive');
114
+
115
+ const sendEvent = (type: string, data: any) => {
116
+ res.write(`data: ${JSON.stringify({ type, data })}\n\n`);
117
+ };
118
+
119
+ try {
120
+ const projectName = customProjectName || `Email-Automator-${crypto.randomBytes(2).toString('hex')}`;
121
+ const region = customRegion || 'us-east-1';
122
+
123
+ // Generate a secure DB password server-side
124
+ const dbPass = crypto.randomBytes(16).toString('base64')
125
+ .replace(/\+/g, 'a')
126
+ .replace(/\//g, 'b')
127
+ .replace(/=/g, 'c') + '1!Aa';
128
+
129
+ sendEvent('info', `🚀 Creating Supabase project: ${projectName} in ${region}...`);
130
+
131
+ // 1. Create Project
132
+ const createResponse = await axios.post('https://api.supabase.com/v1/projects', {
133
+ name: projectName,
134
+ organization_id: orgId,
135
+ region: region,
136
+ db_pass: dbPass
137
+ }, {
138
+ headers: { 'Authorization': authHeader },
139
+ timeout: 15000
140
+ });
141
+
142
+ const project = createResponse.data;
143
+ const projectRef = project.id;
144
+
145
+ sendEvent('info', `📦 Project created! ID: ${projectRef}. Waiting for it to go live...`);
146
+ sendEvent('project_id', projectRef);
147
+
148
+ // 2. Poll for Readiness
149
+ let isReady = false;
150
+ let attempts = 0;
151
+ const maxAttempts = 60; // 5 minutes
152
+
153
+ while (!isReady && attempts < maxAttempts) {
154
+ attempts++;
155
+ await new Promise(resolve => setTimeout(resolve, 5000));
156
+
157
+ try {
158
+ const statusResponse = await axios.get(`https://api.supabase.com/v1/projects/${projectRef}`, {
159
+ headers: { 'Authorization': authHeader },
160
+ timeout: 10000
161
+ });
162
+
163
+ const status = statusResponse.data.status;
164
+ sendEvent('info', `⏳ Status: ${status} (Attempt ${attempts}/${maxAttempts})`);
165
+
166
+ if (status === 'ACTIVE_HEALTHY' || status === 'ACTIVE') {
167
+ isReady = true;
168
+ }
169
+ } catch (pollError: any) {
170
+ logger.warn('Polling error during provision', { error: pollError.message });
171
+ }
172
+ }
173
+
174
+ if (!isReady) {
175
+ throw new Error('Project provision timed out after 5 minutes.');
176
+ }
177
+
178
+ // 3. Get API Keys
179
+ sendEvent('info', '🔑 Retrieving API keys...');
180
+
181
+ let anonKey = '';
182
+ let keyAttempts = 0;
183
+ const maxKeyAttempts = 10;
184
+
185
+ while (!anonKey && keyAttempts < maxKeyAttempts) {
186
+ keyAttempts++;
187
+ if (keyAttempts > 1) {
188
+ sendEvent('info', `⏳ API keys not ready yet. Retrying (Attempt ${keyAttempts}/${maxKeyAttempts})...`);
189
+ await new Promise(resolve => setTimeout(resolve, 3000));
190
+ }
191
+
192
+ try {
193
+ const keysResponse = await axios.get(`https://api.supabase.com/v1/projects/${projectRef}/api-keys`, {
194
+ headers: { 'Authorization': authHeader },
195
+ timeout: 10000
196
+ });
197
+
198
+ const keys = keysResponse.data;
199
+ if (Array.isArray(keys)) {
200
+ anonKey = keys.find((k: any) => k.name === 'anon')?.api_key;
201
+ if (anonKey) {
202
+ sendEvent('info', '✅ API keys retrieved successfully.');
203
+ }
204
+ }
205
+ } catch (err: any) {
206
+ logger.warn('Key retrieval attempt failed', { error: err.message });
207
+ }
208
+ }
209
+
210
+ if (!anonKey) {
211
+ throw new Error('Could not find anonymous API key for the new project.');
212
+ }
213
+
214
+ const supabaseUrl = `https://${projectRef}.supabase.co`;
215
+
216
+ // 4. DNS Verification
217
+ sendEvent('info', '🌐 Waiting for DNS propagation...');
218
+ let dnsReady = false;
219
+ let dnsAttempts = 0;
220
+ const maxDnsAttempts = 20;
221
+
222
+ while (!dnsReady && dnsAttempts < maxDnsAttempts) {
223
+ dnsAttempts++;
224
+ try {
225
+ const pingResponse = await axios.get(`${supabaseUrl}/rest/v1/`, {
226
+ timeout: 5000,
227
+ validateStatus: () => true
228
+ });
229
+ if (pingResponse.status < 500) {
230
+ dnsReady = true;
231
+ sendEvent('info', '✨ DNS resolved! Project is fully accessible.');
232
+ }
233
+ } catch (pingError: any) {
234
+ if (dnsAttempts % 5 === 0) {
235
+ sendEvent('info', '⏳ DNS still propagating... standby.');
236
+ }
237
+ await new Promise(resolve => setTimeout(resolve, 3000));
238
+ }
239
+ }
240
+
241
+ sendEvent('success', {
242
+ url: supabaseUrl,
243
+ anonKey: anonKey,
244
+ projectId: projectRef,
245
+ dbPass: dbPass
246
+ });
247
+
248
+ sendEvent('done', 'success');
249
+ } catch (error: any) {
250
+ const errorMsg = error.response?.data?.message || error.message || 'Auto-provisioning failed';
251
+ logger.error('Auto-provision failed', { error: errorMsg });
252
+ sendEvent('error', errorMsg);
253
+ sendEvent('done', 'failed');
254
+ } finally {
255
+ res.end();
256
+ }
257
+ });
258
+
70
259
  // Robust resolution for static assets (dist folder)
71
260
  function getDistPath() {
72
261
  // 1. Check environment variable override (must contain index.html)
@@ -103,9 +292,9 @@ app.get(/.*/, (req, res, next) => {
103
292
  res.sendFile(path.join(distPath, 'index.html'), (err) => {
104
293
  if (err) {
105
294
  // If dist doesn't exist, return 404 for non-API routes
106
- res.status(404).json({
107
- success: false,
108
- error: { code: 'NOT_FOUND', message: 'Frontend not built or endpoint not found' }
295
+ res.status(404).json({
296
+ success: false,
297
+ error: { code: 'NOT_FOUND', message: 'Frontend not built or endpoint not found' }
109
298
  });
110
299
  }
111
300
  });
@@ -131,7 +320,7 @@ const server = app.listen(config.port, () => {
131
320
  environment: config.nodeEnv,
132
321
  supabase: getServerSupabase() ? 'connected' : 'not configured',
133
322
  });
134
-
323
+
135
324
  // Start background scheduler
136
325
  if (getServerSupabase()) {
137
326
  startScheduler();
@@ -82,8 +82,7 @@ export const schemas = {
82
82
  // Migration schemas
83
83
  migrate: z.object({
84
84
  projectRef: z.string().min(1, 'Project reference is required'),
85
- dbPassword: z.string().optional(),
86
- accessToken: z.string().optional(),
85
+ accessToken: z.string().min(1, 'Access token is required for automatic migration'),
87
86
  }),
88
87
 
89
88
  // Rule schemas - supports both single action (legacy) and actions array
@@ -116,9 +115,8 @@ export const schemas = {
116
115
 
117
116
  // Settings schemas
118
117
  updateSettings: z.object({
118
+ llm_provider: z.string().optional(),
119
119
  llm_model: z.string().optional(),
120
- llm_base_url: z.string().url().optional().or(z.literal('')),
121
- llm_api_key: z.string().optional(),
122
120
  auto_trash_spam: z.boolean().optional(),
123
121
  smart_drafts: z.boolean().optional(),
124
122
  sync_interval_minutes: z.number().min(1).max(60).optional(),
@@ -152,16 +152,7 @@ router.post('/draft/:emailId',
152
152
  throw new NotFoundError('Email');
153
153
  }
154
154
 
155
- // Get user settings for LLM config
156
- const { data: settings } = await req.supabase!
157
- .from('user_settings')
158
- .select('llm_model, llm_base_url')
159
- .eq('user_id', userId)
160
- .single();
161
-
162
- const intelligenceService = getIntelligenceService(
163
- settings ? { model: settings.llm_model, baseUrl: settings.llm_base_url } : undefined
164
- );
155
+ const intelligenceService = getIntelligenceService();
165
156
 
166
157
  const draft = await intelligenceService.generateDraftReply(
167
158
  {
@@ -7,6 +7,7 @@ import rulesRoutes from './rules.js';
7
7
  import settingsRoutes from './settings.js';
8
8
  import emailsRoutes from './emails.js';
9
9
  import migrateRoutes from './migrate.js';
10
+ import sdkRoutes from './sdk.js';
10
11
 
11
12
  const router = Router();
12
13
 
@@ -18,5 +19,6 @@ router.use('/rules', rulesRoutes);
18
19
  router.use('/settings', settingsRoutes);
19
20
  router.use('/emails', emailsRoutes);
20
21
  router.use('/migrate', migrateRoutes);
22
+ router.use('/sdk', sdkRoutes);
21
23
 
22
24
  export default router;
@@ -13,65 +13,71 @@ const logger = createLogger('MigrateRoutes');
13
13
  router.post('/',
14
14
  validateBody(schemas.migrate),
15
15
  asyncHandler(async (req, res) => {
16
- const { projectRef, dbPassword, accessToken } = req.body;
16
+ const { projectRef, accessToken } = req.body;
17
17
 
18
18
  logger.info('Starting migration', { projectRef });
19
19
 
20
- res.setHeader('Content-Type', 'text/plain');
21
- res.setHeader('Transfer-Encoding', 'chunked');
20
+ res.setHeader('Content-Type', 'text/event-stream');
21
+ res.setHeader('Cache-Control', 'no-cache');
22
+ res.setHeader('Connection', 'keep-alive');
22
23
 
23
- const sendLog = (message: string) => {
24
- res.write(message + '\n');
24
+ const sendEvent = (type: string, data: string) => {
25
+ res.write(`data: ${JSON.stringify({ type, data })}\n\n`);
25
26
  };
26
27
 
27
28
  try {
28
- sendLog('🔧 Starting migration...');
29
-
29
+ sendEvent('info', '🔧 Starting migration...');
30
+
30
31
  const scriptPath = join(config.scriptsDir, 'migrate.sh');
31
32
 
32
33
  const env = {
33
34
  ...process.env,
34
35
  SUPABASE_PROJECT_ID: projectRef,
35
- SUPABASE_DB_PASSWORD: dbPassword || '',
36
36
  SUPABASE_ACCESS_TOKEN: accessToken || '',
37
- SKIP_FUNCTIONS: '1',
37
+ SKIP_FUNCTIONS: '0',
38
38
  };
39
39
 
40
- const child = spawn('bash', [scriptPath], {
41
- env,
40
+ const child = spawn('bash', [scriptPath], {
41
+ env,
42
42
  cwd: config.rootDir,
43
43
  });
44
44
 
45
45
  child.stdout.on('data', (data) => {
46
- sendLog(data.toString().trim());
46
+ const lines = data.toString().split('\n').filter((l: string) => l.trim());
47
+ for (const line of lines) {
48
+ sendEvent('stdout', line);
49
+ }
47
50
  });
48
51
 
49
52
  child.stderr.on('data', (data) => {
50
- sendLog(`⚠️ ${data.toString().trim()}`);
53
+ const lines = data.toString().split('\n').filter((l: string) => l.trim());
54
+ for (const line of lines) {
55
+ sendEvent('stderr', line);
56
+ }
51
57
  });
52
58
 
53
59
  child.on('close', (code) => {
54
60
  if (code === 0) {
55
- sendLog('✅ Migration successful!');
56
- sendLog('RESULT: success');
61
+ sendEvent('info', '✅ Migration completed successfully!');
62
+ sendEvent('done', 'success');
57
63
  logger.info('Migration completed successfully', { projectRef });
58
64
  } else {
59
- sendLog(`❌ Migration failed with code ${code}`);
60
- sendLog('RESULT: failure');
65
+ sendEvent('error', `❌ Migration failed with code ${code}`);
66
+ sendEvent('done', 'failed');
61
67
  logger.error('Migration failed', new Error(`Exit code: ${code}`), { projectRef });
62
68
  }
63
69
  res.end();
64
70
  });
65
71
 
66
72
  child.on('error', (error) => {
67
- sendLog(`❌ Failed to run migration: ${error.message}`);
68
- sendLog('RESULT: failure');
73
+ sendEvent('error', `❌ Failed to run migration: ${error.message}`);
74
+ sendEvent('done', 'failed');
69
75
  logger.error('Migration spawn error', error);
70
76
  res.end();
71
77
  });
72
78
  } catch (error: any) {
73
- sendLog(`❌ Server error: ${error.message}`);
74
- sendLog('RESULT: failure');
79
+ sendEvent('error', `❌ Server error: ${error.message}`);
80
+ sendEvent('done', 'failed');
75
81
  logger.error('Migration error', error);
76
82
  res.end();
77
83
  }
@@ -0,0 +1,55 @@
1
+ import { Router, Request, Response } from 'express';
2
+ import { SDKService } from '../services/SDKService.js';
3
+ import { ProvidersResponse } from '@realtimex/sdk';
4
+
5
+ const router = Router();
6
+
7
+ /**
8
+ * GET /api/sdk/providers/chat
9
+ * Returns available chat providers and their models
10
+ */
11
+ router.get('/providers/chat', async (req: Request, res: Response) => {
12
+ try {
13
+ const sdk = SDKService.getSDK();
14
+ if (!sdk) {
15
+ return res.json({ success: false, message: 'SDK not available', providers: [] });
16
+ }
17
+
18
+ const { providers } = await SDKService.withTimeout<ProvidersResponse>(
19
+ sdk.llm.chatProviders(),
20
+ 30000,
21
+ 'Chat providers fetch timed out'
22
+ );
23
+
24
+ res.json({ success: true, providers: providers || [] });
25
+ } catch (error: any) {
26
+ console.warn('[SDK API] Failed to fetch chat providers:', error.message);
27
+ res.json({ success: false, providers: [], message: error.message });
28
+ }
29
+ });
30
+
31
+ /**
32
+ * GET /api/sdk/providers/embed
33
+ * Returns available embedding providers and their models
34
+ */
35
+ router.get('/providers/embed', async (req: Request, res: Response) => {
36
+ try {
37
+ const sdk = SDKService.getSDK();
38
+ if (!sdk) {
39
+ return res.json({ success: false, message: 'SDK not available', providers: [] });
40
+ }
41
+
42
+ const { providers } = await SDKService.withTimeout<ProvidersResponse>(
43
+ sdk.llm.embedProviders(),
44
+ 30000,
45
+ 'Embed providers fetch timed out'
46
+ );
47
+
48
+ res.json({ success: true, providers: providers || [] });
49
+ } catch (error: any) {
50
+ console.warn('[SDK API] Failed to fetch embed providers:', error.message);
51
+ res.json({ success: false, providers: [], message: error.message });
52
+ }
53
+ });
54
+
55
+ export default router;
@@ -4,6 +4,7 @@ import { authMiddleware } from '../middleware/auth.js';
4
4
  import { apiRateLimit } from '../middleware/rateLimit.js';
5
5
  import { validateBody, schemas } from '../middleware/validation.js';
6
6
  import { createLogger } from '../utils/logger.js';
7
+ import { SDKService } from '../services/SDKService.js';
7
8
 
8
9
  const router = Router();
9
10
  const logger = createLogger('SettingsRoutes');
@@ -28,13 +29,21 @@ router.get('/',
28
29
  const settingsData = settingsResult.data;
29
30
  const integrationsData = integrationsResult.data || [];
30
31
 
31
- // Return defaults if no settings exist
32
- const settings = settingsData || {
33
- llm_model: null,
34
- llm_base_url: null,
32
+ // Return defaults if no settings exist, or upgrade old defaults
33
+ let settings = settingsData || {
34
+ llm_provider: SDKService.DEFAULT_LLM_PROVIDER,
35
+ llm_model: 'gpt-4o-mini',
35
36
  sync_interval_minutes: 5,
36
37
  };
37
38
 
39
+ // Proactive upgrade for existing settings objects
40
+ if (settings.llm_model === 'gpt-4.1-mini' || !settings.llm_model) {
41
+ settings.llm_model = 'gpt-4o-mini';
42
+ }
43
+ if (!settings.llm_provider) {
44
+ settings.llm_provider = SDKService.DEFAULT_LLM_PROVIDER;
45
+ }
46
+
38
47
  // Merge integration credentials back into settings for frontend compatibility
39
48
  const googleIntegration = integrationsData.find((i: any) => i.provider === 'google');
40
49
  if (googleIntegration?.credentials) {
@@ -67,7 +76,7 @@ router.patch('/',
67
76
  microsoft_tenant_id,
68
77
  ...userSettingsUpdates
69
78
  } = req.body;
70
-
79
+
71
80
  const userId = req.user!.id;
72
81
 
73
82
  // 1. Update user_settings
@@ -91,7 +100,7 @@ router.patch('/',
91
100
  .eq('user_id', userId)
92
101
  .eq('provider', 'google')
93
102
  .single();
94
-
103
+
95
104
  const credentials: any = {};
96
105
  if (google_client_id) credentials.client_id = google_client_id;
97
106
  if (google_client_secret) credentials.client_secret = google_client_secret;
@@ -110,19 +119,19 @@ router.patch('/',
110
119
 
111
120
  // 3. Handle Microsoft Integration
112
121
  if (microsoft_client_id || microsoft_client_secret || microsoft_tenant_id) {
113
- const { data: existing } = await req.supabase!
122
+ const { data: existing } = await req.supabase!
114
123
  .from('integrations')
115
124
  .select('credentials')
116
125
  .eq('user_id', userId)
117
126
  .eq('provider', 'microsoft')
118
127
  .single();
119
128
 
120
- const credentials: any = {};
121
- if (microsoft_client_id) credentials.client_id = microsoft_client_id;
122
- if (microsoft_client_secret) credentials.client_secret = microsoft_client_secret;
123
- if (microsoft_tenant_id) credentials.tenant_id = microsoft_tenant_id;
129
+ const credentials: any = {};
130
+ if (microsoft_client_id) credentials.client_id = microsoft_client_id;
131
+ if (microsoft_client_secret) credentials.client_secret = microsoft_client_secret;
132
+ if (microsoft_tenant_id) credentials.tenant_id = microsoft_tenant_id;
124
133
 
125
- const newCredentials = { ...(existing?.credentials || {}), ...credentials };
134
+ const newCredentials = { ...(existing?.credentials || {}), ...credentials };
126
135
 
127
136
  await req.supabase!
128
137
  .from('integrations')
@@ -156,14 +165,8 @@ router.post('/test-llm',
156
165
  apiRateLimit,
157
166
  authMiddleware,
158
167
  asyncHandler(async (req, res) => {
159
- const { llm_model, llm_base_url, llm_api_key } = req.body;
160
-
161
168
  const { getIntelligenceService } = await import('../services/intelligence.js');
162
- const intelligence = getIntelligenceService({
163
- model: llm_model,
164
- baseUrl: llm_base_url,
165
- apiKey: llm_api_key,
166
- });
169
+ const intelligence = getIntelligenceService();
167
170
 
168
171
  const result = await intelligence.testConnection();
169
172
  res.json(result);