@realtimex/email-automator 2.24.0 → 2.26.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
@@ -14,7 +14,7 @@ import routes from './src/routes/index.js';
14
14
  import { logger } from './src/utils/logger.js';
15
15
  import { getServerSupabase, getServiceRoleSupabase } from './src/services/supabase.js';
16
16
  import { startScheduler, stopScheduler } from './src/services/scheduler.js';
17
- import { setEncryptionKey } from './src/utils/encryption.js';
17
+ import { setEncryptionKey, getEncryptionKeyHex } from './src/utils/encryption.js';
18
18
 
19
19
  // Initialize Persistence Encryption
20
20
  // NOTE: In RealTimeX Desktop sandbox, all config is stored in Supabase (no .env files)
@@ -83,6 +83,44 @@ async function initializePersistenceEncryption() {
83
83
  }
84
84
  }
85
85
 
86
+ // Periodic sync: Persist in-memory encryption key to users without one
87
+ // This handles the "first user" case and any users created before key was persisted
88
+ async function syncEncryptionKeyToUsers() {
89
+ try {
90
+ const supabase = getServiceRoleSupabase();
91
+ if (!supabase) return;
92
+
93
+ const currentKey = getEncryptionKeyHex();
94
+ if (!currentKey) return; // No key in memory yet
95
+
96
+ // Find users without encryption key
97
+ const { data: usersWithoutKey, error } = await supabase
98
+ .from('user_settings')
99
+ .select('user_id')
100
+ .is('encryption_key', null)
101
+ .limit(100);
102
+
103
+ if (error || !usersWithoutKey || usersWithoutKey.length === 0) {
104
+ return; // No users to update
105
+ }
106
+
107
+ logger.info(`Found ${usersWithoutKey.length} user(s) without encryption key, persisting...`);
108
+
109
+ // Update all users without a key
110
+ const updates = usersWithoutKey.map(user =>
111
+ supabase
112
+ .from('user_settings')
113
+ .update({ encryption_key: currentKey })
114
+ .eq('user_id', user.user_id)
115
+ );
116
+
117
+ await Promise.all(updates);
118
+ logger.info(`✓ Persisted encryption key to ${usersWithoutKey.length} user(s)`);
119
+ } catch (err) {
120
+ logger.warn('Error syncing encryption key to users:', { error: err });
121
+ }
122
+ }
123
+
86
124
  const __filename = fileURLToPath(import.meta.url);
87
125
  const __dirname = path.dirname(__filename);
88
126
 
@@ -386,6 +424,9 @@ const startServer = async () => {
386
424
  // Try to load encryption key before accepting requests
387
425
  await initializePersistenceEncryption();
388
426
 
427
+ // Initial sync to persist key to any users without one (handles first user case)
428
+ setTimeout(() => syncEncryptionKeyToUsers(), 500); // Wait 500ms for DB to be ready
429
+
389
430
  const server = app.listen(config.port, () => {
390
431
  const url = `http://localhost:${config.port}`;
391
432
  logger.info(`Server running at ${url}`, {
@@ -397,6 +438,19 @@ const startServer = async () => {
397
438
  if (getServerSupabase()) {
398
439
  startScheduler();
399
440
  }
441
+
442
+ // Periodic sync: persist encryption key to new users every 30 seconds for first 5 minutes
443
+ // This ensures first user gets the key even if created after server start
444
+ let syncCount = 0;
445
+ const syncInterval = setInterval(() => {
446
+ syncEncryptionKeyToUsers();
447
+ syncCount++;
448
+ if (syncCount >= 10) { // 10 * 30s = 5 minutes
449
+ clearInterval(syncInterval);
450
+ // After 5 minutes, sync less frequently (every 5 minutes)
451
+ setInterval(syncEncryptionKeyToUsers, 5 * 60 * 1000);
452
+ }
453
+ }, 30 * 1000); // Every 30 seconds
400
454
  });
401
455
 
402
456
  // Handle server errors
@@ -1,4 +1,4 @@
1
1
  export { errorHandler, asyncHandler, AppError, ValidationError, AuthenticationError, AuthorizationError, NotFoundError, RateLimitError } from './errorHandler.js';
2
2
  export { authMiddleware, optionalAuth, requireRole } from './auth.js';
3
- export { rateLimit, authRateLimit, apiRateLimit, syncRateLimit } from './rateLimit.js';
3
+ export { rateLimit, authRateLimit, apiRateLimit, syncRateLimit, connectionRateLimit } from './rateLimit.js';
4
4
  export { validateBody, validateQuery, validateParams, schemas } from './validation.js';
@@ -85,3 +85,9 @@ export const syncRateLimit = rateLimit({
85
85
  windowMs: 60 * 1000, // 1 minute
86
86
  max: 20, // 20 sync requests per minute
87
87
  });
88
+
89
+ // Connection testing rate limit (lenient for troubleshooting)
90
+ export const connectionRateLimit = rateLimit({
91
+ windowMs: 15 * 60 * 1000, // 15 minutes
92
+ max: 30, // 30 connection attempts per 15 minutes
93
+ });
@@ -1,19 +1,20 @@
1
1
  import { Router } from 'express';
2
2
  import { asyncHandler, ValidationError } from '../middleware/errorHandler.js';
3
3
  import { authMiddleware } from '../middleware/auth.js';
4
- import { authRateLimit } from '../middleware/rateLimit.js';
4
+ import { authRateLimit, connectionRateLimit } from '../middleware/rateLimit.js';
5
5
  import { validateBody, schemas } from '../middleware/validation.js';
6
6
  import { getGmailService } from '../services/gmail.js';
7
7
  import { getMicrosoftService } from '../services/microsoft.js';
8
8
  import { getImapService } from '../services/imap-service.js';
9
9
  import { createLogger } from '../utils/logger.js';
10
+ import { getEncryptionKeyHex } from '../utils/encryption.js';
10
11
 
11
12
  const router = Router();
12
13
  const logger = createLogger('AuthRoutes');
13
14
 
14
15
  // IMAP/SMTP Connection
15
16
  router.post('/imap/connect',
16
- authRateLimit,
17
+ connectionRateLimit, // More lenient for connection testing (30 attempts / 15 min)
17
18
  authMiddleware,
18
19
  validateBody(schemas.imapConnect),
19
20
  asyncHandler(async (req, res) => {
@@ -23,6 +24,27 @@ router.post('/imap/connect',
23
24
  smtpHost, smtpPort, smtpSecure
24
25
  } = req.body;
25
26
 
27
+ // CRITICAL FIX: Ensure user has encryption key before IMAP operations
28
+ // This handles the "first user connects IMAP immediately after signup" case
29
+ const currentKey = getEncryptionKeyHex();
30
+ if (currentKey) {
31
+ const { data: userSettings } = await req.supabase!
32
+ .from('user_settings')
33
+ .select('encryption_key')
34
+ .eq('user_id', req.user!.id)
35
+ .single();
36
+
37
+ if (userSettings && !userSettings.encryption_key) {
38
+ // User doesn't have encryption key yet - persist it now
39
+ logger.info('User missing encryption key, persisting immediately', { userId: req.user!.id });
40
+ await req.supabase!
41
+ .from('user_settings')
42
+ .update({ encryption_key: currentKey })
43
+ .eq('user_id', req.user!.id);
44
+ logger.info('✓ Encryption key persisted to user');
45
+ }
46
+ }
47
+
26
48
  const imapService = getImapService();
27
49
  const imapConfig = {
28
50
  host: imapHost,
@@ -813,10 +813,22 @@ export class EmailProcessorService {
813
813
 
814
814
  if (eventLogger) await eventLogger.info('Processing', `Background processing: ${email.subject}`, undefined, email.id);
815
815
 
816
+ // Timeline tracking for performance metrics
817
+ const timeline = {
818
+ start: Date.now(),
819
+ parsed: 0,
820
+ metadata_extracted: 0,
821
+ llm_analysis: 0,
822
+ validation: 0,
823
+ actions: 0,
824
+ end: 0
825
+ };
826
+
816
827
  // 2. Read content from disk and parse with mailparser
817
828
  if (!email.file_path) throw new Error('No file path found for email');
818
829
  const rawMime = await this.storageService.readEmail(email.file_path);
819
830
  const parsed = await simpleParser(rawMime);
831
+ timeline.parsed = Date.now();
820
832
 
821
833
  // Extract clean content (prioritize text)
822
834
  const cleanContent = parsed.text || parsed.textAsHtml || '';
@@ -835,6 +847,40 @@ export class EmailProcessorService {
835
847
  sender_priority: email.sender_priority || undefined,
836
848
  thread_id: email.thread_id || undefined,
837
849
  };
850
+ timeline.metadata_extracted = Date.now();
851
+
852
+ // Calculate email age
853
+ const emailAge = email.date ? Math.floor((Date.now() - new Date(email.date).getTime()) / (1000 * 60 * 60 * 24)) : 0;
854
+
855
+ // Check for VIP sender and learned patterns (for metadata event)
856
+ const senderDomain = email.sender?.split('@')[1];
857
+ const learnedCategory = senderDomain && settings?.category_patterns?.[senderDomain];
858
+ const isVIP = email.sender && settings?.vip_senders?.includes(email.sender);
859
+
860
+ // Log Email Metadata event for trace UI
861
+ if (eventLogger) {
862
+ await eventLogger.info('Email Context',
863
+ `Email metadata extracted: ${emailAge} days old, ${metadata.recipient_type || 'TO'} recipient`,
864
+ {
865
+ email_age_days: emailAge,
866
+ email_date: email.date,
867
+ recipient_type: metadata.recipient_type || 'to',
868
+ is_automated: metadata.is_automated || false,
869
+ has_unsubscribe: metadata.has_unsubscribe || false,
870
+ is_reply: metadata.is_reply || false,
871
+ is_thread: !!metadata.thread_id,
872
+ sender_priority: metadata.sender_priority || 'normal',
873
+ mailer: metadata.mailer || 'unknown',
874
+ vip_sender: isVIP || false,
875
+ vip_sender_email: isVIP ? email.sender : null,
876
+ learned_category: learnedCategory || null,
877
+ learned_domain: learnedCategory ? senderDomain : null,
878
+ sender: email.sender,
879
+ subject: email.subject
880
+ },
881
+ email.id
882
+ );
883
+ }
838
884
 
839
885
  // 3. Fetch account for action execution
840
886
  const { data: account } = await this.supabase
@@ -971,9 +1017,13 @@ export class EmailProcessorService {
971
1017
  if (!analysis) {
972
1018
  throw new Error('AI analysis returned no result');
973
1019
  }
1020
+ timeline.llm_analysis = Date.now();
974
1021
 
975
1022
  // PHASE 2: Post-LLM Validation - Filter out incorrectly matched rules
976
1023
  // This catches any LLM hallucinations or fuzzy matches that don't meet actual conditions
1024
+ // Track detailed validation results for trace UI
1025
+ const validationDetails: any[] = [];
1026
+
977
1027
  if (analysis.matched_rules && analysis.matched_rules.length > 0 && rules) {
978
1028
  const emailAge = email.date ? Math.floor((Date.now() - new Date(email.date).getTime()) / (1000 * 60 * 60 * 24)) : 0;
979
1029
  const validatedMatches = [];
@@ -1012,6 +1062,17 @@ export class EmailProcessorService {
1012
1062
  min_confidence: minConfidence,
1013
1063
  email_id: email.id
1014
1064
  });
1065
+
1066
+ // Track validation failure for trace UI
1067
+ validationDetails.push({
1068
+ rule_name: rule.name,
1069
+ rule_id: rule.id,
1070
+ status: 'FILTERED_CONFIDENCE',
1071
+ confidence: match.confidence,
1072
+ min_confidence: minConfidence,
1073
+ reason: `Confidence ${(match.confidence * 100).toFixed(0)}% below threshold ${(minConfidence * 100).toFixed(0)}%`
1074
+ });
1075
+
1015
1076
  if (eventLogger) {
1016
1077
  await eventLogger.info('Validation',
1017
1078
  `Rule "${rule.name}" below confidence threshold (${(match.confidence * 100).toFixed(0)}% < ${(minConfidence * 100).toFixed(0)}%)`,
@@ -1049,6 +1110,18 @@ export class EmailProcessorService {
1049
1110
  negative_condition: rule.negative_condition,
1050
1111
  email_id: email.id
1051
1112
  });
1113
+
1114
+ // Track validation failure for trace UI
1115
+ validationDetails.push({
1116
+ rule_name: rule.name,
1117
+ rule_id: rule.id,
1118
+ status: 'FILTERED_NEGATIVE_CONDITION',
1119
+ confidence: match.confidence,
1120
+ min_confidence: minConfidence,
1121
+ negative_condition: rule.negative_condition,
1122
+ reason: 'Excluded by negative condition'
1123
+ });
1124
+
1052
1125
  if (eventLogger) {
1053
1126
  await eventLogger.info('Validation',
1054
1127
  `Rule "${rule.name}" excluded by negative condition`,
@@ -1063,8 +1136,28 @@ export class EmailProcessorService {
1063
1136
  }
1064
1137
 
1065
1138
  if (isValid && !isExcluded) {
1139
+ // Track successful validation for trace UI
1140
+ validationDetails.push({
1141
+ rule_name: rule.name,
1142
+ rule_id: rule.id,
1143
+ status: 'MATCHED',
1144
+ confidence: match.confidence,
1145
+ min_confidence: minConfidence,
1146
+ reasoning: match.reasoning,
1147
+ reason: 'All conditions met, confidence above threshold'
1148
+ });
1066
1149
  validatedMatches.push(match);
1067
- } else {
1150
+ } else if (!isExcluded) {
1151
+ // Track condition failure for trace UI
1152
+ validationDetails.push({
1153
+ rule_name: rule.name,
1154
+ rule_id: rule.id,
1155
+ status: 'FILTERED_CONDITIONS',
1156
+ confidence: match.confidence,
1157
+ min_confidence: minConfidence,
1158
+ reason: 'LLM matched but rule conditions not met'
1159
+ });
1160
+
1068
1161
  logger.info('Filtered out invalid LLM rule match', {
1069
1162
  rule_name: rule.name,
1070
1163
  rule_id: rule.id,
@@ -1090,6 +1183,9 @@ export class EmailProcessorService {
1090
1183
  analysis.matched_rules = validatedMatches;
1091
1184
  }
1092
1185
 
1186
+ // Mark validation complete
1187
+ timeline.validation = Date.now();
1188
+
1093
1189
  // Log detailed rule evaluation for debugging
1094
1190
  if (eventLogger && rules) {
1095
1191
  const emailAge = email.date ? Math.floor((Date.now() - new Date(email.date).getTime()) / (1000 * 60 * 60 * 24)) : 0;
@@ -1162,8 +1258,17 @@ export class EmailProcessorService {
1162
1258
  const learnedCategory = senderDomain && settings?.category_patterns?.[senderDomain];
1163
1259
  const isVIP = email.sender && settings?.vip_senders?.includes(email.sender);
1164
1260
 
1261
+ // Count validation results
1262
+ const validationSummary = {
1263
+ llm_matched: validationDetails.length,
1264
+ final_matched: validationDetails.filter(v => v.status === 'MATCHED').length,
1265
+ filtered_confidence: validationDetails.filter(v => v.status === 'FILTERED_CONFIDENCE').length,
1266
+ filtered_negative: validationDetails.filter(v => v.status === 'FILTERED_NEGATIVE_CONDITION').length,
1267
+ filtered_conditions: validationDetails.filter(v => v.status === 'FILTERED_CONDITIONS').length
1268
+ };
1269
+
1165
1270
  await eventLogger.info('Rule Evaluation',
1166
- `Evaluated ${ruleEvaluations.length} rules: ${analysis.matched_rules.length} matched, ${ruleEvaluations.length - analysis.matched_rules.length} failed`,
1271
+ `Evaluated ${ruleEvaluations.length} rules: ${validationSummary.final_matched} matched, ${validationSummary.llm_matched - validationSummary.final_matched} filtered`,
1167
1272
  {
1168
1273
  ai_analysis: {
1169
1274
  category: analysis.category,
@@ -1171,6 +1276,8 @@ export class EmailProcessorService {
1171
1276
  email_age_days: emailAge,
1172
1277
  summary: analysis.summary
1173
1278
  },
1279
+ validation_summary: validationSummary,
1280
+ validation_details: validationDetails,
1174
1281
  learned_patterns_applied: {
1175
1282
  category_override: learnedCategory ? {
1176
1283
  domain: senderDomain,
@@ -1373,6 +1480,47 @@ export class EmailProcessorService {
1373
1480
  );
1374
1481
  }
1375
1482
 
1483
+ // Mark actions complete
1484
+ timeline.actions = Date.now();
1485
+ timeline.end = Date.now();
1486
+
1487
+ // Log performance summary
1488
+ if (eventLogger) {
1489
+ const performanceMetrics = {
1490
+ total_time_ms: timeline.end - timeline.start,
1491
+ parse_time_ms: timeline.parsed - timeline.start,
1492
+ metadata_extraction_ms: timeline.metadata_extracted - timeline.parsed,
1493
+ llm_analysis_ms: timeline.llm_analysis - timeline.metadata_extracted,
1494
+ validation_ms: timeline.validation - timeline.llm_analysis,
1495
+ actions_ms: timeline.actions - timeline.validation,
1496
+ finalization_ms: timeline.end - timeline.actions
1497
+ };
1498
+
1499
+ const breakdown = [
1500
+ `Parse: ${performanceMetrics.parse_time_ms}ms`,
1501
+ `Metadata: ${performanceMetrics.metadata_extraction_ms}ms`,
1502
+ `LLM: ${performanceMetrics.llm_analysis_ms}ms`,
1503
+ `Validation: ${performanceMetrics.validation_ms}ms`,
1504
+ `Actions: ${performanceMetrics.actions_ms}ms`
1505
+ ].join(', ');
1506
+
1507
+ await eventLogger.info('Performance',
1508
+ `Completed in ${performanceMetrics.total_time_ms}ms (${breakdown})`,
1509
+ {
1510
+ timeline: performanceMetrics,
1511
+ stages: {
1512
+ parse: { duration_ms: performanceMetrics.parse_time_ms, percent: ((performanceMetrics.parse_time_ms / performanceMetrics.total_time_ms) * 100).toFixed(1) },
1513
+ metadata: { duration_ms: performanceMetrics.metadata_extraction_ms, percent: ((performanceMetrics.metadata_extraction_ms / performanceMetrics.total_time_ms) * 100).toFixed(1) },
1514
+ llm: { duration_ms: performanceMetrics.llm_analysis_ms, percent: ((performanceMetrics.llm_analysis_ms / performanceMetrics.total_time_ms) * 100).toFixed(1) },
1515
+ validation: { duration_ms: performanceMetrics.validation_ms, percent: ((performanceMetrics.validation_ms / performanceMetrics.total_time_ms) * 100).toFixed(1) },
1516
+ actions: { duration_ms: performanceMetrics.actions_ms, percent: ((performanceMetrics.actions_ms / performanceMetrics.total_time_ms) * 100).toFixed(1) },
1517
+ finalization: { duration_ms: performanceMetrics.finalization_ms, percent: ((performanceMetrics.finalization_ms / performanceMetrics.total_time_ms) * 100).toFixed(1) }
1518
+ }
1519
+ },
1520
+ email.id
1521
+ );
1522
+ }
1523
+
1376
1524
  // Mark log as success
1377
1525
  if (log) {
1378
1526
  await this.supabase
@@ -15,6 +15,10 @@ export function setEncryptionKey(hexKey: string) {
15
15
  // console.debug('Encryption key updated from persistence');
16
16
  }
17
17
 
18
+ export function getEncryptionKeyHex(): string | null {
19
+ return secretKey ? secretKey.toString('hex') : null;
20
+ }
21
+
18
22
  function getKey(): Buffer {
19
23
  if (!secretKey) {
20
24
  throw new Error(
@@ -13,7 +13,7 @@ import routes from './src/routes/index.js';
13
13
  import { logger } from './src/utils/logger.js';
14
14
  import { getServerSupabase, getServiceRoleSupabase } from './src/services/supabase.js';
15
15
  import { startScheduler, stopScheduler } from './src/services/scheduler.js';
16
- import { setEncryptionKey } from './src/utils/encryption.js';
16
+ import { setEncryptionKey, getEncryptionKeyHex } from './src/utils/encryption.js';
17
17
  // Initialize Persistence Encryption
18
18
  // NOTE: In RealTimeX Desktop sandbox, all config is stored in Supabase (no .env files)
19
19
  async function initializePersistenceEncryption() {
@@ -71,6 +71,38 @@ async function initializePersistenceEncryption() {
71
71
  logger.warn('⚠ IMAP features may not work properly');
72
72
  }
73
73
  }
74
+ // Periodic sync: Persist in-memory encryption key to users without one
75
+ // This handles the "first user" case and any users created before key was persisted
76
+ async function syncEncryptionKeyToUsers() {
77
+ try {
78
+ const supabase = getServiceRoleSupabase();
79
+ if (!supabase)
80
+ return;
81
+ const currentKey = getEncryptionKeyHex();
82
+ if (!currentKey)
83
+ return; // No key in memory yet
84
+ // Find users without encryption key
85
+ const { data: usersWithoutKey, error } = await supabase
86
+ .from('user_settings')
87
+ .select('user_id')
88
+ .is('encryption_key', null)
89
+ .limit(100);
90
+ if (error || !usersWithoutKey || usersWithoutKey.length === 0) {
91
+ return; // No users to update
92
+ }
93
+ logger.info(`Found ${usersWithoutKey.length} user(s) without encryption key, persisting...`);
94
+ // Update all users without a key
95
+ const updates = usersWithoutKey.map(user => supabase
96
+ .from('user_settings')
97
+ .update({ encryption_key: currentKey })
98
+ .eq('user_id', user.user_id));
99
+ await Promise.all(updates);
100
+ logger.info(`✓ Persisted encryption key to ${usersWithoutKey.length} user(s)`);
101
+ }
102
+ catch (err) {
103
+ logger.warn('Error syncing encryption key to users:', { error: err });
104
+ }
105
+ }
74
106
  const __filename = fileURLToPath(import.meta.url);
75
107
  const __dirname = path.dirname(__filename);
76
108
  // Validate configuration
@@ -331,6 +363,8 @@ process.on('SIGINT', shutdown);
331
363
  const startServer = async () => {
332
364
  // Try to load encryption key before accepting requests
333
365
  await initializePersistenceEncryption();
366
+ // Initial sync to persist key to any users without one (handles first user case)
367
+ setTimeout(() => syncEncryptionKeyToUsers(), 500); // Wait 500ms for DB to be ready
334
368
  const server = app.listen(config.port, () => {
335
369
  const url = `http://localhost:${config.port}`;
336
370
  logger.info(`Server running at ${url}`, {
@@ -341,6 +375,18 @@ const startServer = async () => {
341
375
  if (getServerSupabase()) {
342
376
  startScheduler();
343
377
  }
378
+ // Periodic sync: persist encryption key to new users every 30 seconds for first 5 minutes
379
+ // This ensures first user gets the key even if created after server start
380
+ let syncCount = 0;
381
+ const syncInterval = setInterval(() => {
382
+ syncEncryptionKeyToUsers();
383
+ syncCount++;
384
+ if (syncCount >= 10) { // 10 * 30s = 5 minutes
385
+ clearInterval(syncInterval);
386
+ // After 5 minutes, sync less frequently (every 5 minutes)
387
+ setInterval(syncEncryptionKeyToUsers, 5 * 60 * 1000);
388
+ }
389
+ }, 30 * 1000); // Every 30 seconds
344
390
  });
345
391
  // Handle server errors
346
392
  server.on('error', (error) => {
@@ -1,4 +1,4 @@
1
1
  export { errorHandler, asyncHandler, AppError, ValidationError, AuthenticationError, AuthorizationError, NotFoundError, RateLimitError } from './errorHandler.js';
2
2
  export { authMiddleware, optionalAuth, requireRole } from './auth.js';
3
- export { rateLimit, authRateLimit, apiRateLimit, syncRateLimit } from './rateLimit.js';
3
+ export { rateLimit, authRateLimit, apiRateLimit, syncRateLimit, connectionRateLimit } from './rateLimit.js';
4
4
  export { validateBody, validateQuery, validateParams, schemas } from './validation.js';
@@ -55,3 +55,8 @@ export const syncRateLimit = rateLimit({
55
55
  windowMs: 60 * 1000, // 1 minute
56
56
  max: 20, // 20 sync requests per minute
57
57
  });
58
+ // Connection testing rate limit (lenient for troubleshooting)
59
+ export const connectionRateLimit = rateLimit({
60
+ windowMs: 15 * 60 * 1000, // 15 minutes
61
+ max: 30, // 30 connection attempts per 15 minutes
62
+ });
@@ -1,17 +1,38 @@
1
1
  import { Router } from 'express';
2
2
  import { asyncHandler, ValidationError } from '../middleware/errorHandler.js';
3
3
  import { authMiddleware } from '../middleware/auth.js';
4
- import { authRateLimit } from '../middleware/rateLimit.js';
4
+ import { authRateLimit, connectionRateLimit } from '../middleware/rateLimit.js';
5
5
  import { validateBody, schemas } from '../middleware/validation.js';
6
6
  import { getGmailService } from '../services/gmail.js';
7
7
  import { getMicrosoftService } from '../services/microsoft.js';
8
8
  import { getImapService } from '../services/imap-service.js';
9
9
  import { createLogger } from '../utils/logger.js';
10
+ import { getEncryptionKeyHex } from '../utils/encryption.js';
10
11
  const router = Router();
11
12
  const logger = createLogger('AuthRoutes');
12
13
  // IMAP/SMTP Connection
13
- router.post('/imap/connect', authRateLimit, authMiddleware, validateBody(schemas.imapConnect), asyncHandler(async (req, res) => {
14
+ router.post('/imap/connect', connectionRateLimit, // More lenient for connection testing (30 attempts / 15 min)
15
+ authMiddleware, validateBody(schemas.imapConnect), asyncHandler(async (req, res) => {
14
16
  const { email, password, imapHost, imapPort, imapSecure, smtpHost, smtpPort, smtpSecure } = req.body;
17
+ // CRITICAL FIX: Ensure user has encryption key before IMAP operations
18
+ // This handles the "first user connects IMAP immediately after signup" case
19
+ const currentKey = getEncryptionKeyHex();
20
+ if (currentKey) {
21
+ const { data: userSettings } = await req.supabase
22
+ .from('user_settings')
23
+ .select('encryption_key')
24
+ .eq('user_id', req.user.id)
25
+ .single();
26
+ if (userSettings && !userSettings.encryption_key) {
27
+ // User doesn't have encryption key yet - persist it now
28
+ logger.info('User missing encryption key, persisting immediately', { userId: req.user.id });
29
+ await req.supabase
30
+ .from('user_settings')
31
+ .update({ encryption_key: currentKey })
32
+ .eq('user_id', req.user.id);
33
+ logger.info('✓ Encryption key persisted to user');
34
+ }
35
+ }
15
36
  const imapService = getImapService();
16
37
  const imapConfig = {
17
38
  host: imapHost,