@realtimex/email-automator 2.28.0 → 2.28.1

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.
@@ -5,6 +5,7 @@ import { config } from '../config/index.js';
5
5
  import { getGmailService, GmailMessage } from './gmail.js';
6
6
  import { getMicrosoftService, OutlookMessage } from './microsoft.js';
7
7
  import { getImapService, EmailMessage as ImapMessage } from './imap-service.js';
8
+ import { initializePersistenceEncryption, isEncryptionReady } from './encryptionInit.js';
8
9
  import { getIntelligenceService, EmailAnalysis, ContextAwareAnalysis, RuleContext } from './intelligence.js';
9
10
  import { getStorageService } from './storage.js';
10
11
  import { generateEmailFilename } from '../utils/filename.js';
@@ -144,179 +145,189 @@ export class EmailProcessorService {
144
145
  async syncAccount(accountId: string, userId: string): Promise<ProcessingResult> {
145
146
  const result: ProcessingResult = { processed: 0, deleted: 0, drafted: 0, errors: 0 };
146
147
 
147
- // Reset stop request flag at the start of a manual sync
148
- await this.resetStopRequest(userId);
149
-
150
- // Zero-Config UX: Auto-seed default rules for new users (self-healing)
151
148
  try {
152
- const defaultRuleService = new DefaultRuleService(this.supabase);
153
- const { installed } = await defaultRuleService.ensureDefaultRules(userId);
154
- if (installed) {
155
- logger.info(`Seeded default rules for user ${userId}`);
149
+ // Ensure encryption is ready (especially for background syncs)
150
+ if (!isEncryptionReady()) {
151
+ await initializePersistenceEncryption(this.supabase);
156
152
  }
157
- } catch (error) {
158
- // Don't fail sync if pack installation fails
159
- logger.error('Failed to auto-install Universal Pack', error);
160
- }
161
153
 
162
- // Create processing log
163
- const { data: log } = await this.supabase
164
- .from('processing_logs')
165
- .insert({
166
- user_id: userId,
167
- account_id: accountId,
168
- status: 'running',
169
- })
170
- .select()
171
- .single();
154
+ // Reset stop request flag at the start of a manual sync
155
+ await this.resetStopRequest(userId);
172
156
 
173
- try {
174
- // Fetch account
175
- const { data: account, error: accError } = await this.supabase
176
- .from('email_accounts')
177
- .select('*')
178
- .eq('id', accountId)
179
- .eq('user_id', userId)
180
- .single();
181
-
182
- if (accError || !account) {
183
- throw new Error('Account not found or access denied');
184
- }
185
-
186
- logger.info('Retrieved account settings', {
187
- accountId: account.id,
188
- sync_start_date: account.sync_start_date,
189
- last_sync_checkpoint: account.last_sync_checkpoint
190
- });
191
-
192
- // Refresh token if needed
193
- let refreshedAccount = account;
194
- if (account.provider === 'gmail') {
195
- refreshedAccount = await this.gmailService.refreshTokenIfNeeded(this.supabase, account);
196
- } else if (account.provider === 'outlook') {
197
- refreshedAccount = await this.microsoftService.refreshTokenIfNeeded(this.supabase, account);
198
- } else if (account.provider === 'imap') {
199
- // IMAP doesn't need token refresh, but we could verify connection here if we wanted
200
- refreshedAccount = account;
157
+ // Zero-Config UX: Auto-seed default rules for new users (self-healing)
158
+ try {
159
+ const defaultRuleService = new DefaultRuleService(this.supabase);
160
+ const { installed } = await defaultRuleService.ensureDefaultRules(userId);
161
+ if (installed) {
162
+ logger.info(`Seeded default rules for user ${userId}`);
163
+ }
164
+ } catch (error) {
165
+ // Don't fail sync if pack installation fails
166
+ logger.error('Failed to auto-install Universal Pack', error);
201
167
  }
202
168
 
203
- // Update status to syncing
204
- await this.supabase
205
- .from('email_accounts')
206
- .update({
207
- last_sync_status: 'syncing',
208
- last_sync_at: new Date().toISOString()
169
+ // Create processing log
170
+ const { data: log } = await this.supabase
171
+ .from('processing_logs')
172
+ .insert({
173
+ user_id: userId,
174
+ account_id: accountId,
175
+ status: 'running',
209
176
  })
210
- .eq('id', accountId);
211
-
212
- // Fetch user's rules
213
- const { data: rules } = await this.supabase
214
- .from('rules')
215
- .select('*')
216
- .eq('user_id', userId)
217
- .eq('is_enabled', true);
218
-
219
- // Fetch user settings for AI preferences
220
- const { data: settings } = await this.supabase
221
- .from('user_settings')
222
- .select('*')
223
- .eq('user_id', userId)
177
+ .select()
224
178
  .single();
225
179
 
226
- const eventLogger = log ? new EventLogger(this.supabase, log.id) : null;
227
- if (eventLogger) await eventLogger.info('Running', 'Starting sync process');
228
-
229
- // --- STOP CHECK ---
230
- if (await this.checkStopRequested(userId, eventLogger)) return result;
231
-
232
- // Process based on provider
233
180
  try {
234
- if (refreshedAccount.provider === 'gmail') {
235
- await this.processGmailAccount(refreshedAccount, rules || [], settings, result, eventLogger);
236
- } else if (refreshedAccount.provider === 'outlook') {
237
- await this.processOutlookAccount(refreshedAccount, rules || [], settings, result, eventLogger);
238
- } else if (refreshedAccount.provider === 'imap') {
239
- await this.processImapAccount(refreshedAccount, rules || [], settings, result, eventLogger);
181
+ // Fetch account
182
+ const { data: account, error: accError } = await this.supabase
183
+ .from('email_accounts')
184
+ .select('*')
185
+ .eq('id', accountId)
186
+ .eq('user_id', userId)
187
+ .single();
188
+
189
+ if (accError || !account) {
190
+ throw new Error('Account not found or access denied');
240
191
  }
241
- } catch (providerError) {
242
- const providerName = refreshedAccount.provider === 'gmail' ? 'Gmail' :
243
- refreshedAccount.provider === 'outlook' ? 'Outlook' :
244
- 'IMAP';
245
- throw new Error(`${providerName} Sync Error: ${providerError instanceof Error ? providerError.message : String(providerError)}`);
246
- }
247
-
248
- // After processing new emails, run retention rules for this account
249
- if (await this.checkStopRequested(userId, eventLogger)) return result;
250
- await this.runRetentionRules(refreshedAccount, rules || [], settings, result, eventLogger);
251
192
 
252
- // Wait for background worker to process the queue (ensure sync is fully complete before event)
253
- await this.processQueue(userId, settings, result).catch(err =>
254
- logger.error('Background worker failed', err)
255
- );
193
+ logger.info('Retrieved account settings', {
194
+ accountId: account.id,
195
+ sync_start_date: account.sync_start_date,
196
+ last_sync_checkpoint: account.last_sync_checkpoint
197
+ });
256
198
 
257
- // Update log and account on success
258
- if (log) {
259
- if (eventLogger) {
260
- await eventLogger.success('Finished', 'Sync run completed', {
261
- total_processed: result.processed,
262
- deleted: result.deleted,
263
- drafted: result.drafted,
264
- errors: result.errors
265
- });
199
+ // Refresh token if needed
200
+ let refreshedAccount = account;
201
+ if (account.provider === 'gmail') {
202
+ refreshedAccount = await this.gmailService.refreshTokenIfNeeded(this.supabase, account);
203
+ } else if (account.provider === 'outlook') {
204
+ refreshedAccount = await this.microsoftService.refreshTokenIfNeeded(this.supabase, account);
205
+ } else if (account.provider === 'imap') {
206
+ // IMAP doesn't need token refresh, but we could verify connection here if we wanted
207
+ refreshedAccount = account;
266
208
  }
267
209
 
210
+ // Update status to syncing
268
211
  await this.supabase
269
- .from('processing_logs')
212
+ .from('email_accounts')
270
213
  .update({
271
- status: 'success',
272
- completed_at: new Date().toISOString(),
273
- emails_processed: result.processed,
274
- emails_deleted: result.deleted,
275
- emails_drafted: result.drafted,
214
+ last_sync_status: 'syncing',
215
+ last_sync_at: new Date().toISOString()
276
216
  })
277
- .eq('id', log.id);
278
- }
217
+ .eq('id', accountId);
218
+
219
+ // Fetch user's rules
220
+ const { data: rules } = await this.supabase
221
+ .from('rules')
222
+ .select('*')
223
+ .eq('user_id', userId)
224
+ .eq('is_enabled', true);
225
+
226
+ // Fetch user settings for AI preferences
227
+ const { data: settings } = await this.supabase
228
+ .from('user_settings')
229
+ .select('*')
230
+ .eq('user_id', userId)
231
+ .single();
232
+
233
+ const eventLogger = log ? new EventLogger(this.supabase, log.id) : null;
234
+ if (eventLogger) await eventLogger.info('Running', 'Starting sync process');
235
+
236
+ // --- STOP CHECK ---
237
+ if (await this.checkStopRequested(userId, eventLogger)) return result;
238
+
239
+ // Process based on provider
240
+ try {
241
+ if (refreshedAccount.provider === 'gmail') {
242
+ await this.processGmailAccount(refreshedAccount, rules || [], settings, result, eventLogger);
243
+ } else if (refreshedAccount.provider === 'outlook') {
244
+ await this.processOutlookAccount(refreshedAccount, rules || [], settings, result, eventLogger);
245
+ } else if (refreshedAccount.provider === 'imap') {
246
+ await this.processImapAccount(refreshedAccount, rules || [], settings, result, eventLogger);
247
+ }
248
+ } catch (providerError) {
249
+ const providerName = refreshedAccount.provider === 'gmail' ? 'Gmail' :
250
+ refreshedAccount.provider === 'outlook' ? 'Outlook' :
251
+ 'IMAP';
252
+ throw new Error(`${providerName} Sync Error: ${providerError instanceof Error ? providerError.message : String(providerError)}`);
253
+ }
279
254
 
280
- await this.supabase
281
- .from('email_accounts')
282
- .update({
283
- last_sync_status: 'success',
284
- last_sync_error: null,
285
- sync_start_date: null // Clear manual override once used successfully
286
- })
287
- .eq('id', accountId);
255
+ // After processing new emails, run retention rules for this account
256
+ if (await this.checkStopRequested(userId, eventLogger)) return result;
257
+ await this.runRetentionRules(refreshedAccount, rules || [], settings, result, eventLogger);
288
258
 
289
- logger.info('Sync completed and override cleared', { accountId, ...result });
290
- } catch (error) {
291
- logger.error('Sync failed', error, { accountId });
259
+ // Wait for background worker to process the queue (ensure sync is fully complete before event)
260
+ await this.processQueue(userId, settings, result).catch(err =>
261
+ logger.error('Background worker failed', err)
262
+ );
263
+
264
+ // Update log and account on success
265
+ if (log) {
266
+ if (eventLogger) {
267
+ await eventLogger.success('Finished', 'Sync run completed', {
268
+ total_processed: result.processed,
269
+ deleted: result.deleted,
270
+ drafted: result.drafted,
271
+ errors: result.errors
272
+ });
273
+ }
292
274
 
293
- const errMsg = error instanceof Error ? error.message : 'Unknown error';
275
+ await this.supabase
276
+ .from('processing_logs')
277
+ .update({
278
+ status: 'success',
279
+ completed_at: new Date().toISOString(),
280
+ emails_processed: result.processed,
281
+ emails_deleted: result.deleted,
282
+ emails_drafted: result.drafted,
283
+ })
284
+ .eq('id', log.id);
285
+ }
294
286
 
295
- if (log) {
296
287
  await this.supabase
297
- .from('processing_logs')
288
+ .from('email_accounts')
298
289
  .update({
299
- status: 'failed',
300
- completed_at: new Date().toISOString(),
301
- error_message: errMsg,
290
+ last_sync_status: 'success',
291
+ last_sync_error: null,
292
+ sync_start_date: null // Clear manual override once used successfully
302
293
  })
303
- .eq('id', log.id);
304
- }
294
+ .eq('id', accountId);
305
295
 
306
- await this.supabase
307
- .from('email_accounts')
308
- .update({
309
- last_sync_status: 'error',
310
- last_sync_error: errMsg
311
- })
312
- .eq('id', accountId);
296
+ logger.info('Sync completed and override cleared', { accountId, ...result });
297
+ } catch (error) {
298
+ logger.error('Sync failed', error, { accountId });
299
+
300
+ const errMsg = error instanceof Error ? error.message : 'Unknown error';
301
+
302
+ if (log) {
303
+ await this.supabase
304
+ .from('processing_logs')
305
+ .update({
306
+ status: 'failed',
307
+ completed_at: new Date().toISOString(),
308
+ error_message: errMsg,
309
+ })
310
+ .eq('id', log.id);
311
+ }
313
312
 
314
- // If it's a fatal setup error (e.g. Account not found), throw it
315
- if (errMsg.includes('Account not found') || errMsg.includes('access denied')) {
316
- throw error;
317
- }
313
+ await this.supabase
314
+ .from('email_accounts')
315
+ .update({
316
+ last_sync_status: 'error',
317
+ last_sync_error: errMsg
318
+ })
319
+ .eq('id', accountId);
320
+
321
+ // If it's a fatal setup error (e.g. Account not found), throw it
322
+ if (errMsg.includes('Account not found') || errMsg.includes('access denied')) {
323
+ throw error;
324
+ }
318
325
 
319
- // Otherwise, increment error count and return partial results
326
+ // Otherwise, increment error count and return partial results
327
+ result.errors++;
328
+ }
329
+ } catch (globalError) {
330
+ logger.error('Global sync execution failed', globalError);
320
331
  result.errors++;
321
332
  }
322
333
 
@@ -11,112 +11,9 @@ import { errorHandler } from './src/middleware/errorHandler.js';
11
11
  import { apiRateLimit } from './src/middleware/rateLimit.js';
12
12
  import routes from './src/routes/index.js';
13
13
  import { logger } from './src/utils/logger.js';
14
- import { getServerSupabase, getServiceRoleSupabase } from './src/services/supabase.js';
14
+ import { getServerSupabase } from './src/services/supabase.js';
15
15
  import { startScheduler, stopScheduler } from './src/services/scheduler.js';
16
- import { setEncryptionKey, getEncryptionKeyHex } from './src/utils/encryption.js';
17
- // Initialize Persistence Encryption
18
- // NOTE: In RealTimeX Desktop sandbox, all config is stored in Supabase (no .env files)
19
- async function initializePersistenceEncryption() {
20
- try {
21
- const supabase = getServiceRoleSupabase();
22
- if (!supabase) {
23
- // BYOK mode: Supabase not configured at startup (credentials come via HTTP headers)
24
- // Generate encryption key anyway and keep it in memory
25
- logger.info('Supabase not configured yet (BYOK mode)');
26
- logger.info('Generating encryption key in memory - will persist when Supabase becomes available');
27
- const newKey = crypto.randomBytes(32).toString('hex');
28
- setEncryptionKey(newKey);
29
- logger.info('✓ Encryption key generated and loaded in memory');
30
- return;
31
- }
32
- // 1. Check if ANY user has an encryption key stored
33
- // In sandbox mode, encryption key is always in database
34
- const { data: users, error } = await supabase
35
- .from('user_settings')
36
- .select('user_id, encryption_key')
37
- .not('encryption_key', 'is', null)
38
- .limit(1);
39
- if (error) {
40
- logger.warn('Failed to query user_settings for encryption key', { error });
41
- // Still generate a key for in-memory use
42
- const newKey = crypto.randomBytes(32).toString('hex');
43
- setEncryptionKey(newKey);
44
- logger.info('✓ Generated fallback encryption key due to DB error');
45
- return;
46
- }
47
- if (users && users.length > 0 && users[0].encryption_key) {
48
- // Found a persisted key in database
49
- logger.info('✓ Loaded encryption key from database');
50
- setEncryptionKey(users[0].encryption_key);
51
- return;
52
- }
53
- // 2. No key in database - auto-generate and persist
54
- // This happens on first run or after fresh database setup
55
- logger.info('No encryption key found in database, generating new key...');
56
- const newKey = crypto.randomBytes(32).toString('hex');
57
- setEncryptionKey(newKey);
58
- // 3. Persist to all existing users in database
59
- const { data: allUsers } = await supabase
60
- .from('user_settings')
61
- .select('user_id')
62
- .limit(100);
63
- if (allUsers && allUsers.length > 0) {
64
- logger.info(`Saving encryption key to database for ${allUsers.length} user(s)...`);
65
- // Update all users with the new key
66
- const updates = allUsers.map(user => supabase
67
- .from('user_settings')
68
- .update({ encryption_key: newKey })
69
- .eq('user_id', user.user_id));
70
- await Promise.all(updates);
71
- logger.info('✓ Encryption key saved to database');
72
- }
73
- else {
74
- logger.info('No users found yet, encryption key loaded in memory');
75
- logger.info('Key will be persisted when users are created');
76
- }
77
- }
78
- catch (err) {
79
- logger.error('Error initializing encryption:', err);
80
- // Always ensure we have a key, even if there was an error
81
- if (!getEncryptionKeyHex()) {
82
- logger.warn('Generating emergency fallback encryption key');
83
- const fallbackKey = crypto.randomBytes(32).toString('hex');
84
- setEncryptionKey(fallbackKey);
85
- }
86
- }
87
- }
88
- // Periodic sync: Persist in-memory encryption key to users without one
89
- // This handles the "first user" case and any users created before key was persisted
90
- async function syncEncryptionKeyToUsers() {
91
- try {
92
- const supabase = getServiceRoleSupabase();
93
- if (!supabase)
94
- return;
95
- const currentKey = getEncryptionKeyHex();
96
- if (!currentKey)
97
- return; // No key in memory yet
98
- // Find users without encryption key
99
- const { data: usersWithoutKey, error } = await supabase
100
- .from('user_settings')
101
- .select('user_id')
102
- .is('encryption_key', null)
103
- .limit(100);
104
- if (error || !usersWithoutKey || usersWithoutKey.length === 0) {
105
- return; // No users to update
106
- }
107
- logger.info(`Found ${usersWithoutKey.length} user(s) without encryption key, persisting...`);
108
- // Update all users without a key
109
- const updates = usersWithoutKey.map(user => supabase
110
- .from('user_settings')
111
- .update({ encryption_key: currentKey })
112
- .eq('user_id', user.user_id));
113
- await Promise.all(updates);
114
- logger.info(`✓ Persisted encryption key to ${usersWithoutKey.length} user(s)`);
115
- }
116
- catch (err) {
117
- logger.warn('Error syncing encryption key to users:', { error: err });
118
- }
119
- }
16
+ import { initializePersistenceEncryption, syncEncryptionKeyToUsers } from './src/services/encryptionInit.js';
120
17
  const __filename = fileURLToPath(import.meta.url);
121
18
  const __dirname = path.dirname(__filename);
122
19
  // Validate configuration
@@ -4,6 +4,7 @@ import { AuthenticationError, AuthorizationError } from './errorHandler.js';
4
4
  import { createLogger, Logger } from '../utils/logger.js';
5
5
  const logger = createLogger('AuthMiddleware');
6
6
  import { getServerSupabase, isValidUrl } from '../services/supabase.js';
7
+ import { initializePersistenceEncryption, isEncryptionReady } from '../services/encryptionInit.js';
7
8
  // Check if anon key looks valid (JWT or publishable key format)
8
9
  function isValidAnonKey(key) {
9
10
  if (!key)
@@ -32,6 +33,17 @@ export async function authMiddleware(req, _res, next) {
32
33
  const isEnvKeyValid = !!envKey && envKey.length > 0;
33
34
  const supabaseUrl = isEnvUrlValid ? envUrl : (headerConfig?.url || '');
34
35
  const supabaseAnonKey = isEnvKeyValid ? envKey : (headerConfig?.anonKey || '');
36
+ // If encryption is not ready, try to initialize it using available Supabase config
37
+ if (!isEncryptionReady() && supabaseUrl && supabaseAnonKey) {
38
+ // Note: Ideally we use service role to read encryption_key from user_settings,
39
+ // but even with anon key it might work if RLS allows or if we just use it
40
+ // to check for existence of keys.
41
+ const initClient = createClient(supabaseUrl, supabaseAnonKey, {
42
+ auth: { autoRefreshToken: false, persistSession: false },
43
+ });
44
+ // Run in background to not block auth
45
+ initializePersistenceEncryption(initClient).catch(err => logger.warn('Failed to initialize encryption in auth middleware', { error: err.message }));
46
+ }
35
47
  // Development bypass: skip auth if DISABLE_AUTH=true in non-production
36
48
  if (config.security.disableAuth && !config.isProduction) {
37
49
  logger.warn('Auth disabled for development - creating mock user');
@@ -7,57 +7,15 @@ 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, setEncryptionKey } from '../utils/encryption.js';
10
+ import { initializePersistenceEncryption } from '../services/encryptionInit.js';
11
11
  const router = Router();
12
12
  const logger = createLogger('AuthRoutes');
13
13
  // IMAP/SMTP Connection
14
14
  router.post('/imap/connect', connectionRateLimit, // More lenient for connection testing (30 attempts / 15 min)
15
15
  authMiddleware, validateBody(schemas.imapConnect), asyncHandler(async (req, res) => {
16
16
  const { email, password, imapHost, imapPort, imapSecure, smtpHost, smtpPort, smtpSecure } = req.body;
17
- // CRITICAL FIX: Sync encryption key between server memory and database
18
- // In BYOK mode, database trigger generates key but server has random key in memory
19
- // We need to sync them: prefer database key (persistent) over server key (ephemeral)
20
- const { data: userSettings, error: fetchError } = await req.supabase
21
- .from('user_settings')
22
- .select('encryption_key')
23
- .eq('user_id', req.user.id)
24
- .single();
25
- if (fetchError) {
26
- logger.error('Failed to fetch user settings', { error: fetchError });
27
- throw new ValidationError('Failed to load user settings. Please try again.');
28
- }
29
- const currentServerKey = getEncryptionKeyHex();
30
- const databaseKey = userSettings?.encryption_key;
31
- logger.info('Encryption key sync check', {
32
- hasServerKey: !!currentServerKey,
33
- hasDatabaseKey: !!databaseKey,
34
- userId: req.user.id
35
- });
36
- if (databaseKey) {
37
- // Database has a key - use it (it's the source of truth)
38
- if (currentServerKey !== databaseKey) {
39
- logger.info('Loading encryption key from database (overriding server memory)');
40
- setEncryptionKey(databaseKey);
41
- }
42
- }
43
- else if (currentServerKey) {
44
- // Server has key but database doesn't - persist to database
45
- logger.info('Database missing encryption key, persisting server key');
46
- const { error: updateError } = await req.supabase
47
- .from('user_settings')
48
- .update({ encryption_key: currentServerKey })
49
- .eq('user_id', req.user.id);
50
- if (updateError) {
51
- logger.error('Failed to persist encryption key', { error: updateError });
52
- throw new ValidationError('Failed to initialize encryption. Please try again.');
53
- }
54
- logger.info('✓ Encryption key persisted to database');
55
- }
56
- else {
57
- // Neither server nor database has key - this should never happen with new trigger
58
- logger.error('No encryption key found in server OR database!');
59
- throw new ValidationError('Encryption not initialized. Please contact support.');
60
- }
17
+ // Reconcile encryption key with database before saving credentials
18
+ await initializePersistenceEncryption(req.supabase);
61
19
  const imapService = getImapService();
62
20
  const imapConfig = {
63
21
  host: imapHost,