@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.
- package/api/server.ts +1 -120
- package/api/src/middleware/auth.ts +15 -0
- package/api/src/routes/auth.ts +3 -48
- package/api/src/services/encryptionInit.ts +156 -0
- package/api/src/services/processor.ts +156 -145
- package/dist/api/server.js +2 -105
- package/dist/api/src/middleware/auth.js +12 -0
- package/dist/api/src/routes/auth.js +3 -45
- package/dist/api/src/services/encryptionInit.js +135 -0
- package/dist/api/src/services/processor.js +150 -139
- package/dist/assets/{index-DpVG-8N2.js → index-Xu8f_mT0.js} +3 -3
- package/dist/index.html +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
157
|
-
//
|
|
158
|
-
|
|
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
|
-
//
|
|
161
|
-
await this.supabase
|
|
162
|
-
.from('
|
|
163
|
-
.
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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 (
|
|
195
|
-
await this.
|
|
159
|
+
else if (account.provider === 'outlook') {
|
|
160
|
+
refreshedAccount = await this.microsoftService.refreshTokenIfNeeded(this.supabase, account);
|
|
196
161
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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('
|
|
168
|
+
.from('email_accounts')
|
|
222
169
|
.update({
|
|
223
|
-
|
|
224
|
-
|
|
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',
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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('
|
|
238
|
+
.from('email_accounts')
|
|
247
239
|
.update({
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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',
|
|
244
|
+
.eq('id', accountId);
|
|
245
|
+
logger.info('Sync completed and override cleared', { accountId, ...result });
|
|
253
246
|
}
|
|
254
|
-
|
|
255
|
-
.
|
|
256
|
-
.
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
274
|
+
}
|
|
275
|
+
catch (globalError) {
|
|
276
|
+
logger.error('Global sync execution failed', globalError);
|
|
266
277
|
result.errors++;
|
|
267
278
|
}
|
|
268
279
|
return result;
|