@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.
@@ -0,0 +1,135 @@
1
+ import crypto from 'crypto';
2
+ import { setEncryptionKey, getEncryptionKeyHex } from '../utils/encryption.js';
3
+ import { getServiceRoleSupabase } from './supabase.js';
4
+ import { createLogger } from '../utils/logger.js';
5
+ const logger = createLogger('EncryptionInit');
6
+ let isEncryptionInitialized = false;
7
+ /**
8
+ * Initialize encryption key from Supabase or generate a new one.
9
+ * In BYOK mode, this might be called multiple times as different Supabase clients become available.
10
+ *
11
+ * @param providedSupabase Optional Supabase client (service role recommended)
12
+ */
13
+ export async function initializePersistenceEncryption(providedSupabase) {
14
+ try {
15
+ const supabase = providedSupabase || getServiceRoleSupabase();
16
+ if (!supabase) {
17
+ // BYOK mode: Supabase not configured at startup (credentials come via HTTP headers)
18
+ // If we don't have a key yet, generate a temporary one in memory
19
+ if (!getEncryptionKeyHex()) {
20
+ logger.info('Supabase not configured yet (BYOK mode)');
21
+ logger.info('Generating temporary encryption key in memory - will be reconciled when Supabase becomes available');
22
+ const newKey = crypto.randomBytes(32).toString('hex');
23
+ setEncryptionKey(newKey);
24
+ logger.info('✓ Temporary encryption key generated and loaded in memory');
25
+ }
26
+ return;
27
+ }
28
+ // 1. Check if ANY user has an encryption key stored
29
+ // In sandbox mode, encryption key is always in database
30
+ const { data: users, error } = await supabase
31
+ .from('user_settings')
32
+ .select('user_id, encryption_key')
33
+ .not('encryption_key', 'is', null)
34
+ .limit(1);
35
+ if (error) {
36
+ logger.warn('Failed to query user_settings for encryption key', { error });
37
+ // If no key in memory, generate one as fallback
38
+ if (!getEncryptionKeyHex()) {
39
+ const newKey = crypto.randomBytes(32).toString('hex');
40
+ setEncryptionKey(newKey);
41
+ logger.info('✓ Generated fallback encryption key due to DB error');
42
+ }
43
+ return;
44
+ }
45
+ if (users && users.length > 0 && users[0].encryption_key) {
46
+ // Found a persisted key in database
47
+ const dbKey = users[0].encryption_key;
48
+ const currentKey = getEncryptionKeyHex();
49
+ if (currentKey && currentKey !== dbKey) {
50
+ logger.warn('⚠️ In-memory encryption key differs from database. Overwriting with DB key to ensure consistency across instances.');
51
+ }
52
+ logger.info('✓ Loaded encryption key from database');
53
+ setEncryptionKey(dbKey);
54
+ isEncryptionInitialized = true;
55
+ return;
56
+ }
57
+ // 2. No key in database - use in-memory key or generate and persist
58
+ // This happens on first run or after fresh database setup
59
+ let finalKey = getEncryptionKeyHex();
60
+ if (!finalKey) {
61
+ logger.info('No encryption key found in database or memory, generating new key...');
62
+ finalKey = crypto.randomBytes(32).toString('hex');
63
+ setEncryptionKey(finalKey);
64
+ }
65
+ else {
66
+ logger.info('Persisting in-memory encryption key to new Supabase instance...');
67
+ }
68
+ // 3. Persist to all existing users in database
69
+ const { data: allUsers } = await supabase
70
+ .from('user_settings')
71
+ .select('user_id')
72
+ .limit(100);
73
+ if (allUsers && allUsers.length > 0) {
74
+ logger.info(`Saving encryption key to database for ${allUsers.length} user(s)...`);
75
+ // Update all users with the new key
76
+ const updates = allUsers.map((user) => supabase
77
+ .from('user_settings')
78
+ .update({ encryption_key: finalKey })
79
+ .eq('user_id', user.user_id));
80
+ await Promise.all(updates);
81
+ logger.info('✓ Encryption key saved to database');
82
+ }
83
+ else {
84
+ logger.info('No users found yet, encryption key loaded in memory');
85
+ logger.info('Key will be persisted when users are created');
86
+ }
87
+ isEncryptionInitialized = true;
88
+ }
89
+ catch (err) {
90
+ logger.error('Error initializing encryption:', err);
91
+ // Always ensure we have a key, even if there was an error
92
+ if (!getEncryptionKeyHex()) {
93
+ logger.warn('Generating emergency fallback encryption key');
94
+ const fallbackKey = crypto.randomBytes(32).toString('hex');
95
+ setEncryptionKey(fallbackKey);
96
+ }
97
+ }
98
+ }
99
+ /**
100
+ * Periodic sync: Persist in-memory encryption key to users without one
101
+ * This handles the "first user" case and any users created before key was persisted
102
+ */
103
+ export async function syncEncryptionKeyToUsers() {
104
+ try {
105
+ const supabase = getServiceRoleSupabase();
106
+ if (!supabase)
107
+ return;
108
+ const currentKey = getEncryptionKeyHex();
109
+ if (!currentKey)
110
+ return; // No key in memory yet
111
+ // Find users without encryption key
112
+ const { data: usersWithoutKey, error } = await supabase
113
+ .from('user_settings')
114
+ .select('user_id')
115
+ .is('encryption_key', null)
116
+ .limit(100);
117
+ if (error || !usersWithoutKey || usersWithoutKey.length === 0) {
118
+ return; // No users to update
119
+ }
120
+ logger.info(`Found ${usersWithoutKey.length} user(s) without encryption key, persisting...`);
121
+ // Update all users without a key
122
+ const updates = usersWithoutKey.map((user) => supabase
123
+ .from('user_settings')
124
+ .update({ encryption_key: currentKey })
125
+ .eq('user_id', user.user_id));
126
+ await Promise.all(updates);
127
+ logger.info(`✓ Persisted encryption key to ${usersWithoutKey.length} user(s)`);
128
+ }
129
+ catch (err) {
130
+ logger.warn('Error syncing encryption key to users:', { error: err });
131
+ }
132
+ }
133
+ export function isEncryptionReady() {
134
+ return isEncryptionInitialized;
135
+ }
@@ -4,6 +4,7 @@ import { config } from '../config/index.js';
4
4
  import { getGmailService } from './gmail.js';
5
5
  import { getMicrosoftService } from './microsoft.js';
6
6
  import { getImapService } from './imap-service.js';
7
+ import { initializePersistenceEncryption, isEncryptionReady } from './encryptionInit.js';
7
8
  import { getIntelligenceService } from './intelligence.js';
8
9
  import { getStorageService } from './storage.js';
9
10
  import { generateEmailFilename } from '../utils/filename.js';
@@ -105,164 +106,174 @@ export class EmailProcessorService {
105
106
  }
106
107
  async syncAccount(accountId, userId) {
107
108
  const result = { processed: 0, deleted: 0, drafted: 0, errors: 0 };
108
- // Reset stop request flag at the start of a manual sync
109
- await this.resetStopRequest(userId);
110
- // Zero-Config UX: Auto-seed default rules for new users (self-healing)
111
109
  try {
112
- const defaultRuleService = new DefaultRuleService(this.supabase);
113
- const { installed } = await defaultRuleService.ensureDefaultRules(userId);
114
- if (installed) {
115
- logger.info(`Seeded default rules for user ${userId}`);
110
+ // Ensure encryption is ready (especially for background syncs)
111
+ if (!isEncryptionReady()) {
112
+ await initializePersistenceEncryption(this.supabase);
116
113
  }
117
- }
118
- catch (error) {
119
- // Don't fail sync if pack installation fails
120
- logger.error('Failed to auto-install Universal Pack', error);
121
- }
122
- // Create processing log
123
- const { data: log } = await this.supabase
124
- .from('processing_logs')
125
- .insert({
126
- user_id: userId,
127
- account_id: accountId,
128
- status: 'running',
129
- })
130
- .select()
131
- .single();
132
- try {
133
- // Fetch account
134
- const { data: account, error: accError } = await this.supabase
135
- .from('email_accounts')
136
- .select('*')
137
- .eq('id', accountId)
138
- .eq('user_id', userId)
139
- .single();
140
- if (accError || !account) {
141
- throw new Error('Account not found or access denied');
142
- }
143
- logger.info('Retrieved account settings', {
144
- accountId: account.id,
145
- sync_start_date: account.sync_start_date,
146
- last_sync_checkpoint: account.last_sync_checkpoint
147
- });
148
- // Refresh token if needed
149
- let refreshedAccount = account;
150
- if (account.provider === 'gmail') {
151
- refreshedAccount = await this.gmailService.refreshTokenIfNeeded(this.supabase, account);
152
- }
153
- else if (account.provider === 'outlook') {
154
- refreshedAccount = await this.microsoftService.refreshTokenIfNeeded(this.supabase, account);
114
+ // Reset stop request flag at the start of a manual sync
115
+ await this.resetStopRequest(userId);
116
+ // Zero-Config UX: Auto-seed default rules for new users (self-healing)
117
+ try {
118
+ const defaultRuleService = new DefaultRuleService(this.supabase);
119
+ const { installed } = await defaultRuleService.ensureDefaultRules(userId);
120
+ if (installed) {
121
+ logger.info(`Seeded default rules for user ${userId}`);
122
+ }
155
123
  }
156
- else if (account.provider === 'imap') {
157
- // IMAP doesn't need token refresh, but we could verify connection here if we wanted
158
- refreshedAccount = account;
124
+ catch (error) {
125
+ // Don't fail sync if pack installation fails
126
+ logger.error('Failed to auto-install Universal Pack', error);
159
127
  }
160
- // Update status to syncing
161
- await this.supabase
162
- .from('email_accounts')
163
- .update({
164
- last_sync_status: 'syncing',
165
- last_sync_at: new Date().toISOString()
128
+ // Create processing log
129
+ const { data: log } = await this.supabase
130
+ .from('processing_logs')
131
+ .insert({
132
+ user_id: userId,
133
+ account_id: accountId,
134
+ status: 'running',
166
135
  })
167
- .eq('id', accountId);
168
- // Fetch user's rules
169
- const { data: rules } = await this.supabase
170
- .from('rules')
171
- .select('*')
172
- .eq('user_id', userId)
173
- .eq('is_enabled', true);
174
- // Fetch user settings for AI preferences
175
- const { data: settings } = await this.supabase
176
- .from('user_settings')
177
- .select('*')
178
- .eq('user_id', userId)
136
+ .select()
179
137
  .single();
180
- const eventLogger = log ? new EventLogger(this.supabase, log.id) : null;
181
- if (eventLogger)
182
- await eventLogger.info('Running', 'Starting sync process');
183
- // --- STOP CHECK ---
184
- if (await this.checkStopRequested(userId, eventLogger))
185
- return result;
186
- // Process based on provider
187
138
  try {
188
- if (refreshedAccount.provider === 'gmail') {
189
- await this.processGmailAccount(refreshedAccount, rules || [], settings, result, eventLogger);
139
+ // Fetch account
140
+ const { data: account, error: accError } = await this.supabase
141
+ .from('email_accounts')
142
+ .select('*')
143
+ .eq('id', accountId)
144
+ .eq('user_id', userId)
145
+ .single();
146
+ if (accError || !account) {
147
+ throw new Error('Account not found or access denied');
190
148
  }
191
- else if (refreshedAccount.provider === 'outlook') {
192
- await this.processOutlookAccount(refreshedAccount, rules || [], settings, result, eventLogger);
149
+ logger.info('Retrieved account settings', {
150
+ accountId: account.id,
151
+ sync_start_date: account.sync_start_date,
152
+ last_sync_checkpoint: account.last_sync_checkpoint
153
+ });
154
+ // Refresh token if needed
155
+ let refreshedAccount = account;
156
+ if (account.provider === 'gmail') {
157
+ refreshedAccount = await this.gmailService.refreshTokenIfNeeded(this.supabase, account);
193
158
  }
194
- else if (refreshedAccount.provider === 'imap') {
195
- await this.processImapAccount(refreshedAccount, rules || [], settings, result, eventLogger);
159
+ else if (account.provider === 'outlook') {
160
+ refreshedAccount = await this.microsoftService.refreshTokenIfNeeded(this.supabase, account);
196
161
  }
197
- }
198
- catch (providerError) {
199
- const providerName = refreshedAccount.provider === 'gmail' ? 'Gmail' :
200
- refreshedAccount.provider === 'outlook' ? 'Outlook' :
201
- 'IMAP';
202
- throw new Error(`${providerName} Sync Error: ${providerError instanceof Error ? providerError.message : String(providerError)}`);
203
- }
204
- // After processing new emails, run retention rules for this account
205
- if (await this.checkStopRequested(userId, eventLogger))
206
- return result;
207
- await this.runRetentionRules(refreshedAccount, rules || [], settings, result, eventLogger);
208
- // Wait for background worker to process the queue (ensure sync is fully complete before event)
209
- await this.processQueue(userId, settings, result).catch(err => logger.error('Background worker failed', err));
210
- // Update log and account on success
211
- if (log) {
212
- if (eventLogger) {
213
- await eventLogger.success('Finished', 'Sync run completed', {
214
- total_processed: result.processed,
215
- deleted: result.deleted,
216
- drafted: result.drafted,
217
- errors: result.errors
218
- });
162
+ else if (account.provider === 'imap') {
163
+ // IMAP doesn't need token refresh, but we could verify connection here if we wanted
164
+ refreshedAccount = account;
219
165
  }
166
+ // Update status to syncing
220
167
  await this.supabase
221
- .from('processing_logs')
168
+ .from('email_accounts')
222
169
  .update({
223
- status: 'success',
224
- completed_at: new Date().toISOString(),
225
- emails_processed: result.processed,
226
- emails_deleted: result.deleted,
227
- emails_drafted: result.drafted,
170
+ last_sync_status: 'syncing',
171
+ last_sync_at: new Date().toISOString()
228
172
  })
229
- .eq('id', log.id);
230
- }
231
- await this.supabase
232
- .from('email_accounts')
233
- .update({
234
- last_sync_status: 'success',
235
- last_sync_error: null,
236
- sync_start_date: null // Clear manual override once used successfully
237
- })
238
- .eq('id', accountId);
239
- logger.info('Sync completed and override cleared', { accountId, ...result });
240
- }
241
- catch (error) {
242
- logger.error('Sync failed', error, { accountId });
243
- const errMsg = error instanceof Error ? error.message : 'Unknown error';
244
- if (log) {
173
+ .eq('id', accountId);
174
+ // Fetch user's rules
175
+ const { data: rules } = await this.supabase
176
+ .from('rules')
177
+ .select('*')
178
+ .eq('user_id', userId)
179
+ .eq('is_enabled', true);
180
+ // Fetch user settings for AI preferences
181
+ const { data: settings } = await this.supabase
182
+ .from('user_settings')
183
+ .select('*')
184
+ .eq('user_id', userId)
185
+ .single();
186
+ const eventLogger = log ? new EventLogger(this.supabase, log.id) : null;
187
+ if (eventLogger)
188
+ await eventLogger.info('Running', 'Starting sync process');
189
+ // --- STOP CHECK ---
190
+ if (await this.checkStopRequested(userId, eventLogger))
191
+ return result;
192
+ // Process based on provider
193
+ try {
194
+ if (refreshedAccount.provider === 'gmail') {
195
+ await this.processGmailAccount(refreshedAccount, rules || [], settings, result, eventLogger);
196
+ }
197
+ else if (refreshedAccount.provider === 'outlook') {
198
+ await this.processOutlookAccount(refreshedAccount, rules || [], settings, result, eventLogger);
199
+ }
200
+ else if (refreshedAccount.provider === 'imap') {
201
+ await this.processImapAccount(refreshedAccount, rules || [], settings, result, eventLogger);
202
+ }
203
+ }
204
+ catch (providerError) {
205
+ const providerName = refreshedAccount.provider === 'gmail' ? 'Gmail' :
206
+ refreshedAccount.provider === 'outlook' ? 'Outlook' :
207
+ 'IMAP';
208
+ throw new Error(`${providerName} Sync Error: ${providerError instanceof Error ? providerError.message : String(providerError)}`);
209
+ }
210
+ // After processing new emails, run retention rules for this account
211
+ if (await this.checkStopRequested(userId, eventLogger))
212
+ return result;
213
+ await this.runRetentionRules(refreshedAccount, rules || [], settings, result, eventLogger);
214
+ // Wait for background worker to process the queue (ensure sync is fully complete before event)
215
+ await this.processQueue(userId, settings, result).catch(err => logger.error('Background worker failed', err));
216
+ // Update log and account on success
217
+ if (log) {
218
+ if (eventLogger) {
219
+ await eventLogger.success('Finished', 'Sync run completed', {
220
+ total_processed: result.processed,
221
+ deleted: result.deleted,
222
+ drafted: result.drafted,
223
+ errors: result.errors
224
+ });
225
+ }
226
+ await this.supabase
227
+ .from('processing_logs')
228
+ .update({
229
+ status: 'success',
230
+ completed_at: new Date().toISOString(),
231
+ emails_processed: result.processed,
232
+ emails_deleted: result.deleted,
233
+ emails_drafted: result.drafted,
234
+ })
235
+ .eq('id', log.id);
236
+ }
245
237
  await this.supabase
246
- .from('processing_logs')
238
+ .from('email_accounts')
247
239
  .update({
248
- status: 'failed',
249
- completed_at: new Date().toISOString(),
250
- error_message: errMsg,
240
+ last_sync_status: 'success',
241
+ last_sync_error: null,
242
+ sync_start_date: null // Clear manual override once used successfully
251
243
  })
252
- .eq('id', log.id);
244
+ .eq('id', accountId);
245
+ logger.info('Sync completed and override cleared', { accountId, ...result });
253
246
  }
254
- await this.supabase
255
- .from('email_accounts')
256
- .update({
257
- last_sync_status: 'error',
258
- last_sync_error: errMsg
259
- })
260
- .eq('id', accountId);
261
- // If it's a fatal setup error (e.g. Account not found), throw it
262
- if (errMsg.includes('Account not found') || errMsg.includes('access denied')) {
263
- throw error;
247
+ catch (error) {
248
+ logger.error('Sync failed', error, { accountId });
249
+ const errMsg = error instanceof Error ? error.message : 'Unknown error';
250
+ if (log) {
251
+ await this.supabase
252
+ .from('processing_logs')
253
+ .update({
254
+ status: 'failed',
255
+ completed_at: new Date().toISOString(),
256
+ error_message: errMsg,
257
+ })
258
+ .eq('id', log.id);
259
+ }
260
+ await this.supabase
261
+ .from('email_accounts')
262
+ .update({
263
+ last_sync_status: 'error',
264
+ last_sync_error: errMsg
265
+ })
266
+ .eq('id', accountId);
267
+ // If it's a fatal setup error (e.g. Account not found), throw it
268
+ if (errMsg.includes('Account not found') || errMsg.includes('access denied')) {
269
+ throw error;
270
+ }
271
+ // Otherwise, increment error count and return partial results
272
+ result.errors++;
264
273
  }
265
- // Otherwise, increment error count and return partial results
274
+ }
275
+ catch (globalError) {
276
+ logger.error('Global sync execution failed', globalError);
266
277
  result.errors++;
267
278
  }
268
279
  return result;