@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 +55 -1
- package/api/src/middleware/index.ts +1 -1
- package/api/src/middleware/rateLimit.ts +6 -0
- package/api/src/routes/auth.ts +24 -2
- package/api/src/services/processor.ts +150 -2
- package/api/src/utils/encryption.ts +4 -0
- package/dist/api/server.js +47 -1
- package/dist/api/src/middleware/index.js +1 -1
- package/dist/api/src/middleware/rateLimit.js +5 -0
- package/dist/api/src/routes/auth.js +23 -2
- package/dist/api/src/services/processor.js +126 -2
- package/dist/api/src/utils/encryption.js +3 -0
- package/dist/assets/{index-FRMyxVih.js → index-BiE9QzV0.js} +64 -64
- package/dist/assets/index-fVuZ4_5V.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/dist/assets/index-otwjpYTB.css +0 -1
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
|
+
});
|
package/api/src/routes/auth.ts
CHANGED
|
@@ -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
|
-
|
|
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: ${
|
|
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(
|
package/dist/api/server.js
CHANGED
|
@@ -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',
|
|
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,
|