@realtimex/email-automator 2.2.1 → 2.3.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 +4 -2
- package/api/src/config/index.ts +11 -9
- package/bin/email-automator.js +4 -24
- package/dist/api/server.js +109 -0
- package/dist/api/src/config/index.js +89 -0
- package/dist/api/src/middleware/auth.js +119 -0
- package/dist/api/src/middleware/errorHandler.js +78 -0
- package/dist/api/src/middleware/index.js +4 -0
- package/dist/api/src/middleware/rateLimit.js +57 -0
- package/dist/api/src/middleware/validation.js +111 -0
- package/dist/api/src/routes/actions.js +173 -0
- package/dist/api/src/routes/auth.js +106 -0
- package/dist/api/src/routes/emails.js +100 -0
- package/dist/api/src/routes/health.js +33 -0
- package/dist/api/src/routes/index.js +19 -0
- package/dist/api/src/routes/migrate.js +61 -0
- package/dist/api/src/routes/rules.js +104 -0
- package/dist/api/src/routes/settings.js +178 -0
- package/dist/api/src/routes/sync.js +118 -0
- package/dist/api/src/services/eventLogger.js +41 -0
- package/dist/api/src/services/gmail.js +350 -0
- package/dist/api/src/services/intelligence.js +243 -0
- package/dist/api/src/services/microsoft.js +256 -0
- package/dist/api/src/services/processor.js +503 -0
- package/dist/api/src/services/scheduler.js +210 -0
- package/dist/api/src/services/supabase.js +59 -0
- package/dist/api/src/utils/contentCleaner.js +94 -0
- package/dist/api/src/utils/crypto.js +68 -0
- package/dist/api/src/utils/logger.js +119 -0
- package/package.json +5 -4
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
import { createLogger } from '../utils/logger.js';
|
|
2
|
+
import { config } from '../config/index.js';
|
|
3
|
+
import { getGmailService } from './gmail.js';
|
|
4
|
+
import { getMicrosoftService } from './microsoft.js';
|
|
5
|
+
import { getIntelligenceService } from './intelligence.js';
|
|
6
|
+
import { EventLogger } from './eventLogger.js';
|
|
7
|
+
const logger = createLogger('Processor');
|
|
8
|
+
export class EmailProcessorService {
|
|
9
|
+
supabase;
|
|
10
|
+
gmailService = getGmailService();
|
|
11
|
+
microsoftService = getMicrosoftService();
|
|
12
|
+
constructor(supabase) {
|
|
13
|
+
this.supabase = supabase;
|
|
14
|
+
}
|
|
15
|
+
async syncAccount(accountId, userId) {
|
|
16
|
+
const result = { processed: 0, deleted: 0, drafted: 0, errors: 0 };
|
|
17
|
+
// Create processing log
|
|
18
|
+
const { data: log } = await this.supabase
|
|
19
|
+
.from('processing_logs')
|
|
20
|
+
.insert({
|
|
21
|
+
user_id: userId,
|
|
22
|
+
account_id: accountId,
|
|
23
|
+
status: 'running',
|
|
24
|
+
})
|
|
25
|
+
.select()
|
|
26
|
+
.single();
|
|
27
|
+
try {
|
|
28
|
+
// Fetch account
|
|
29
|
+
const { data: account, error: accError } = await this.supabase
|
|
30
|
+
.from('email_accounts')
|
|
31
|
+
.select('*')
|
|
32
|
+
.eq('id', accountId)
|
|
33
|
+
.eq('user_id', userId)
|
|
34
|
+
.single();
|
|
35
|
+
if (accError || !account) {
|
|
36
|
+
throw new Error('Account not found or access denied');
|
|
37
|
+
}
|
|
38
|
+
// Refresh token if needed
|
|
39
|
+
let refreshedAccount = account;
|
|
40
|
+
if (account.provider === 'gmail') {
|
|
41
|
+
refreshedAccount = await this.gmailService.refreshTokenIfNeeded(this.supabase, account);
|
|
42
|
+
}
|
|
43
|
+
// Update status to syncing
|
|
44
|
+
await this.supabase
|
|
45
|
+
.from('email_accounts')
|
|
46
|
+
.update({
|
|
47
|
+
last_sync_status: 'syncing',
|
|
48
|
+
last_sync_at: new Date().toISOString()
|
|
49
|
+
})
|
|
50
|
+
.eq('id', accountId);
|
|
51
|
+
// Fetch user's rules
|
|
52
|
+
const { data: rules } = await this.supabase
|
|
53
|
+
.from('rules')
|
|
54
|
+
.select('*')
|
|
55
|
+
.eq('user_id', userId)
|
|
56
|
+
.eq('is_enabled', true);
|
|
57
|
+
// Fetch user settings for AI preferences
|
|
58
|
+
const { data: settings } = await this.supabase
|
|
59
|
+
.from('user_settings')
|
|
60
|
+
.select('*')
|
|
61
|
+
.eq('user_id', userId)
|
|
62
|
+
.single();
|
|
63
|
+
const eventLogger = log ? new EventLogger(this.supabase, log.id) : null;
|
|
64
|
+
if (eventLogger)
|
|
65
|
+
await eventLogger.info('Running', 'Starting sync process');
|
|
66
|
+
// Process based on provider
|
|
67
|
+
if (refreshedAccount.provider === 'gmail') {
|
|
68
|
+
await this.processGmailAccount(refreshedAccount, rules || [], settings, result, eventLogger);
|
|
69
|
+
}
|
|
70
|
+
else if (refreshedAccount.provider === 'outlook') {
|
|
71
|
+
await this.processOutlookAccount(refreshedAccount, rules || [], settings, result, eventLogger);
|
|
72
|
+
}
|
|
73
|
+
// After processing new emails, run retention rules for this account
|
|
74
|
+
await this.runRetentionRules(refreshedAccount, rules || [], settings, result, eventLogger);
|
|
75
|
+
// Update log and account on success
|
|
76
|
+
if (log) {
|
|
77
|
+
await this.supabase
|
|
78
|
+
.from('processing_logs')
|
|
79
|
+
.update({
|
|
80
|
+
status: 'success',
|
|
81
|
+
completed_at: new Date().toISOString(),
|
|
82
|
+
emails_processed: result.processed,
|
|
83
|
+
emails_deleted: result.deleted,
|
|
84
|
+
emails_drafted: result.drafted,
|
|
85
|
+
})
|
|
86
|
+
.eq('id', log.id);
|
|
87
|
+
}
|
|
88
|
+
await this.supabase
|
|
89
|
+
.from('email_accounts')
|
|
90
|
+
.update({
|
|
91
|
+
last_sync_status: 'success',
|
|
92
|
+
last_sync_error: null
|
|
93
|
+
})
|
|
94
|
+
.eq('id', accountId);
|
|
95
|
+
logger.info('Sync completed', { accountId, ...result });
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
logger.error('Sync failed', error, { accountId });
|
|
99
|
+
const errMsg = error instanceof Error ? error.message : 'Unknown error';
|
|
100
|
+
if (log) {
|
|
101
|
+
await this.supabase
|
|
102
|
+
.from('processing_logs')
|
|
103
|
+
.update({
|
|
104
|
+
status: 'failed',
|
|
105
|
+
completed_at: new Date().toISOString(),
|
|
106
|
+
error_message: errMsg,
|
|
107
|
+
})
|
|
108
|
+
.eq('id', log.id);
|
|
109
|
+
}
|
|
110
|
+
await this.supabase
|
|
111
|
+
.from('email_accounts')
|
|
112
|
+
.update({
|
|
113
|
+
last_sync_status: 'error',
|
|
114
|
+
last_sync_error: errMsg
|
|
115
|
+
})
|
|
116
|
+
.eq('id', accountId);
|
|
117
|
+
// If it's a fatal setup error (e.g. Account not found), throw it
|
|
118
|
+
if (errMsg.includes('Account not found') || errMsg.includes('access denied')) {
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
// Otherwise, increment error count and return partial results
|
|
122
|
+
result.errors++;
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
async processGmailAccount(account, rules, settings, result, eventLogger) {
|
|
127
|
+
const batchSize = account.sync_max_emails_per_run || config.processing.batchSize;
|
|
128
|
+
// Construct query: Use checkpoint if it's newer than the user-defined start date
|
|
129
|
+
let effectiveStartMs = 0;
|
|
130
|
+
if (account.sync_start_date) {
|
|
131
|
+
effectiveStartMs = new Date(account.sync_start_date).getTime();
|
|
132
|
+
}
|
|
133
|
+
if (account.last_sync_checkpoint) {
|
|
134
|
+
const checkpointMs = parseInt(account.last_sync_checkpoint);
|
|
135
|
+
if (checkpointMs > effectiveStartMs) {
|
|
136
|
+
effectiveStartMs = checkpointMs;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
let query = '';
|
|
140
|
+
if (effectiveStartMs > 0) {
|
|
141
|
+
const startSeconds = Math.floor(effectiveStartMs / 1000);
|
|
142
|
+
query = `after:${startSeconds}`;
|
|
143
|
+
}
|
|
144
|
+
if (eventLogger)
|
|
145
|
+
await eventLogger.info('Fetching', 'Fetching emails from Gmail', { query, batchSize });
|
|
146
|
+
const { messages } = await this.gmailService.fetchMessages(account, {
|
|
147
|
+
maxResults: batchSize,
|
|
148
|
+
query: query || undefined,
|
|
149
|
+
});
|
|
150
|
+
if (eventLogger)
|
|
151
|
+
await eventLogger.info('Fetching', `Fetched ${messages.length} emails`);
|
|
152
|
+
// We process in ASCENDING order (oldest to newest) to move checkpoint forward correctly
|
|
153
|
+
const sortedMessages = [...messages].reverse();
|
|
154
|
+
let maxInternalDate = account.last_sync_checkpoint ? parseInt(account.last_sync_checkpoint) : 0;
|
|
155
|
+
for (const message of sortedMessages) {
|
|
156
|
+
try {
|
|
157
|
+
await this.processMessage(account, message, rules, settings, result, eventLogger);
|
|
158
|
+
// Checkpoint tracking
|
|
159
|
+
const msgDate = new Date(message.date).getTime();
|
|
160
|
+
if (msgDate > maxInternalDate) {
|
|
161
|
+
maxInternalDate = msgDate;
|
|
162
|
+
await this.supabase
|
|
163
|
+
.from('email_accounts')
|
|
164
|
+
.update({ last_sync_checkpoint: maxInternalDate.toString() })
|
|
165
|
+
.eq('id', account.id);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
logger.error('Failed to process Gmail message', error, { messageId: message.id });
|
|
170
|
+
if (eventLogger)
|
|
171
|
+
await eventLogger.error('Error', error);
|
|
172
|
+
result.errors++;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async processOutlookAccount(account, rules, settings, result, eventLogger) {
|
|
177
|
+
const batchSize = account.sync_max_emails_per_run || config.processing.batchSize;
|
|
178
|
+
// Construct filter: Use checkpoint if it's newer than the user-defined start date
|
|
179
|
+
let effectiveStartIso = '';
|
|
180
|
+
if (account.sync_start_date) {
|
|
181
|
+
effectiveStartIso = new Date(account.sync_start_date).toISOString();
|
|
182
|
+
}
|
|
183
|
+
if (account.last_sync_checkpoint) {
|
|
184
|
+
if (!effectiveStartIso || account.last_sync_checkpoint > effectiveStartIso) {
|
|
185
|
+
effectiveStartIso = account.last_sync_checkpoint;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
let filter = '';
|
|
189
|
+
if (effectiveStartIso) {
|
|
190
|
+
filter = `receivedDateTime gt ${effectiveStartIso}`;
|
|
191
|
+
}
|
|
192
|
+
if (eventLogger)
|
|
193
|
+
await eventLogger.info('Fetching', 'Fetching emails from Outlook', { filter, batchSize });
|
|
194
|
+
const { messages } = await this.microsoftService.fetchMessages(account, {
|
|
195
|
+
top: batchSize,
|
|
196
|
+
filter: filter || undefined,
|
|
197
|
+
});
|
|
198
|
+
if (eventLogger)
|
|
199
|
+
await eventLogger.info('Fetching', `Fetched ${messages.length} emails`);
|
|
200
|
+
const sortedMessages = [...messages].reverse();
|
|
201
|
+
let latestCheckpoint = account.last_sync_checkpoint || '';
|
|
202
|
+
for (const message of sortedMessages) {
|
|
203
|
+
try {
|
|
204
|
+
await this.processMessage(account, message, rules, settings, result, eventLogger);
|
|
205
|
+
if (!latestCheckpoint || message.date > latestCheckpoint) {
|
|
206
|
+
latestCheckpoint = message.date;
|
|
207
|
+
await this.supabase
|
|
208
|
+
.from('email_accounts')
|
|
209
|
+
.update({ last_sync_checkpoint: latestCheckpoint })
|
|
210
|
+
.eq('id', account.id);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
logger.error('Failed to process Outlook message', error, { messageId: message.id });
|
|
215
|
+
if (eventLogger)
|
|
216
|
+
await eventLogger.error('Error', error);
|
|
217
|
+
result.errors++;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async processMessage(account, message, rules, settings, result, eventLogger) {
|
|
222
|
+
// Check if already processed
|
|
223
|
+
const { data: existing } = await this.supabase
|
|
224
|
+
.from('emails')
|
|
225
|
+
.select('id')
|
|
226
|
+
.eq('account_id', account.id)
|
|
227
|
+
.eq('external_id', message.id)
|
|
228
|
+
.single();
|
|
229
|
+
if (existing) {
|
|
230
|
+
logger.debug('Message already processed', { messageId: message.id });
|
|
231
|
+
if (eventLogger)
|
|
232
|
+
await eventLogger.info('Skipped', `Already processed: ${message.subject}`);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (eventLogger)
|
|
236
|
+
await eventLogger.info('Processing', `Processing email: ${message.subject}`);
|
|
237
|
+
// 1. Create a "Skeleton" record to get an ID for tracing
|
|
238
|
+
const emailData = {
|
|
239
|
+
account_id: account.id,
|
|
240
|
+
external_id: message.id,
|
|
241
|
+
subject: message.subject,
|
|
242
|
+
sender: message.sender,
|
|
243
|
+
recipient: message.recipient,
|
|
244
|
+
date: message.date ? new Date(message.date).toISOString() : null,
|
|
245
|
+
body_snippet: message.snippet || message.body.substring(0, 500),
|
|
246
|
+
};
|
|
247
|
+
const { data: savedEmail, error: saveError } = await this.supabase
|
|
248
|
+
.from('emails')
|
|
249
|
+
.insert(emailData)
|
|
250
|
+
.select()
|
|
251
|
+
.single();
|
|
252
|
+
if (saveError || !savedEmail) {
|
|
253
|
+
logger.error('Failed to create initial email record', saveError);
|
|
254
|
+
if (eventLogger)
|
|
255
|
+
await eventLogger.error('Database Error', saveError);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
// 2. Analyze with AI (passing the ID so events are linked)
|
|
259
|
+
const intelligenceService = getIntelligenceService(settings?.llm_model || settings?.llm_base_url || settings?.llm_api_key
|
|
260
|
+
? {
|
|
261
|
+
model: settings.llm_model,
|
|
262
|
+
baseUrl: settings.llm_base_url,
|
|
263
|
+
apiKey: settings.llm_api_key,
|
|
264
|
+
}
|
|
265
|
+
: undefined);
|
|
266
|
+
const analysis = await intelligenceService.analyzeEmail(message.body, {
|
|
267
|
+
subject: message.subject,
|
|
268
|
+
sender: message.sender,
|
|
269
|
+
date: message.date,
|
|
270
|
+
metadata: message.headers,
|
|
271
|
+
userPreferences: {
|
|
272
|
+
autoTrashSpam: settings?.auto_trash_spam,
|
|
273
|
+
smartDrafts: settings?.smart_drafts,
|
|
274
|
+
},
|
|
275
|
+
}, eventLogger || undefined, savedEmail.id);
|
|
276
|
+
if (!analysis) {
|
|
277
|
+
result.errors++;
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
// 3. Update the email record with results
|
|
281
|
+
await this.supabase
|
|
282
|
+
.from('emails')
|
|
283
|
+
.update({
|
|
284
|
+
category: analysis.category,
|
|
285
|
+
is_useless: analysis.is_useless,
|
|
286
|
+
ai_analysis: analysis,
|
|
287
|
+
suggested_actions: analysis.suggested_actions || [],
|
|
288
|
+
suggested_action: analysis.suggested_actions?.[0] || 'none',
|
|
289
|
+
})
|
|
290
|
+
.eq('id', savedEmail.id);
|
|
291
|
+
const processedEmail = {
|
|
292
|
+
...savedEmail,
|
|
293
|
+
category: analysis.category,
|
|
294
|
+
is_useless: analysis.is_useless,
|
|
295
|
+
suggested_actions: analysis.suggested_actions
|
|
296
|
+
};
|
|
297
|
+
result.processed++;
|
|
298
|
+
// 4. Execute automation rules
|
|
299
|
+
await this.executeRules(account, processedEmail, analysis, rules, settings, result, eventLogger);
|
|
300
|
+
}
|
|
301
|
+
async executeRules(account, email, analysis, rules, settings, result, eventLogger) {
|
|
302
|
+
// User-defined and System rules (Unified)
|
|
303
|
+
for (const rule of rules) {
|
|
304
|
+
if (this.matchesCondition(email, analysis, rule.condition)) {
|
|
305
|
+
let draftContent = undefined;
|
|
306
|
+
// If the rule is to draft, and it has specific instructions, generate it now
|
|
307
|
+
if (rule.action === 'draft' && rule.instructions) {
|
|
308
|
+
if (eventLogger)
|
|
309
|
+
await eventLogger.info('Thinking', `Generating customized draft based on rule: ${rule.name}`, undefined, email.id);
|
|
310
|
+
const intelligenceService = getIntelligenceService(settings?.llm_model || settings?.llm_base_url || settings?.llm_api_key
|
|
311
|
+
? {
|
|
312
|
+
model: settings.llm_model,
|
|
313
|
+
baseUrl: settings.llm_base_url,
|
|
314
|
+
apiKey: settings.llm_api_key,
|
|
315
|
+
}
|
|
316
|
+
: undefined);
|
|
317
|
+
const customizedDraft = await intelligenceService.generateDraftReply({
|
|
318
|
+
subject: email.subject || '',
|
|
319
|
+
sender: email.sender || '',
|
|
320
|
+
body: email.body_snippet || '' // Note: body_snippet is used here, might want full body if available
|
|
321
|
+
}, rule.instructions);
|
|
322
|
+
if (customizedDraft) {
|
|
323
|
+
draftContent = customizedDraft;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
await this.executeAction(account, email, rule.action, draftContent, eventLogger, `Rule: ${rule.name}`, rule.attachments);
|
|
327
|
+
if (rule.action === 'delete')
|
|
328
|
+
result.deleted++;
|
|
329
|
+
else if (rule.action === 'draft')
|
|
330
|
+
result.drafted++;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
matchesCondition(email, analysis, condition) {
|
|
335
|
+
if (!analysis)
|
|
336
|
+
return false;
|
|
337
|
+
for (const [key, value] of Object.entries(condition)) {
|
|
338
|
+
const val = value;
|
|
339
|
+
switch (key) {
|
|
340
|
+
case 'sender_email':
|
|
341
|
+
if (email.sender?.toLowerCase() !== val.toLowerCase())
|
|
342
|
+
return false;
|
|
343
|
+
break;
|
|
344
|
+
case 'sender_domain':
|
|
345
|
+
// Check if sender ends with domain (e.g. @gmail.com)
|
|
346
|
+
const domain = val.startsWith('@') ? val : `@${val}`;
|
|
347
|
+
if (!email.sender?.toLowerCase().endsWith(domain.toLowerCase()))
|
|
348
|
+
return false;
|
|
349
|
+
break;
|
|
350
|
+
case 'sender_contains':
|
|
351
|
+
if (!email.sender?.toLowerCase().includes(val.toLowerCase()))
|
|
352
|
+
return false;
|
|
353
|
+
break;
|
|
354
|
+
case 'subject_contains':
|
|
355
|
+
if (!email.subject?.toLowerCase().includes(val.toLowerCase()))
|
|
356
|
+
return false;
|
|
357
|
+
break;
|
|
358
|
+
case 'body_contains':
|
|
359
|
+
if (!email.body_snippet?.toLowerCase().includes(val.toLowerCase()))
|
|
360
|
+
return false;
|
|
361
|
+
break;
|
|
362
|
+
case 'older_than_days':
|
|
363
|
+
if (!email.date)
|
|
364
|
+
return false;
|
|
365
|
+
const ageInMs = Date.now() - new Date(email.date).getTime();
|
|
366
|
+
const ageInDays = ageInMs / (1000 * 60 * 60 * 24);
|
|
367
|
+
if (ageInDays < value)
|
|
368
|
+
return false;
|
|
369
|
+
break;
|
|
370
|
+
case 'category':
|
|
371
|
+
if (analysis.category !== value)
|
|
372
|
+
return false;
|
|
373
|
+
break;
|
|
374
|
+
case 'priority':
|
|
375
|
+
if (analysis.priority !== value)
|
|
376
|
+
return false;
|
|
377
|
+
break;
|
|
378
|
+
case 'sentiment':
|
|
379
|
+
if (analysis.sentiment !== value)
|
|
380
|
+
return false;
|
|
381
|
+
break;
|
|
382
|
+
case 'is_useless':
|
|
383
|
+
if (analysis.is_useless !== value)
|
|
384
|
+
return false;
|
|
385
|
+
break;
|
|
386
|
+
case 'suggested_actions':
|
|
387
|
+
// Handle array membership check (e.g. if condition expects "reply" to be in actions)
|
|
388
|
+
const requiredActions = Array.isArray(value) ? value : [value];
|
|
389
|
+
const hasAllActions = requiredActions.every(req => analysis.suggested_actions?.includes(req));
|
|
390
|
+
if (!hasAllActions)
|
|
391
|
+
return false;
|
|
392
|
+
break;
|
|
393
|
+
default:
|
|
394
|
+
// Fallback for any other keys that might be in analysis
|
|
395
|
+
if (analysis[key] !== value)
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Scans already processed emails and applies rules that have a time-based condition (retention).
|
|
403
|
+
*/
|
|
404
|
+
async runRetentionRules(account, rules, settings, result, eventLogger) {
|
|
405
|
+
// Find rules that have an age condition
|
|
406
|
+
const retentionRules = rules.filter(r => r.condition.older_than_days !== undefined);
|
|
407
|
+
if (retentionRules.length === 0)
|
|
408
|
+
return;
|
|
409
|
+
if (eventLogger)
|
|
410
|
+
await eventLogger.info('Retention', `Checking retention rules for ${retentionRules.length} policies`);
|
|
411
|
+
// Fetch emails for this account that have been analyzed but haven't had an action taken yet
|
|
412
|
+
const { data: processedEmails, error } = await this.supabase
|
|
413
|
+
.from('emails')
|
|
414
|
+
.select('*')
|
|
415
|
+
.eq('account_id', account.id)
|
|
416
|
+
.is('action_taken', null)
|
|
417
|
+
.not('ai_analysis', 'is', null)
|
|
418
|
+
.order('date', { ascending: true });
|
|
419
|
+
if (error || !processedEmails)
|
|
420
|
+
return;
|
|
421
|
+
for (const email of processedEmails) {
|
|
422
|
+
for (const rule of retentionRules) {
|
|
423
|
+
if (this.matchesCondition(email, email.ai_analysis, rule.condition)) {
|
|
424
|
+
if (eventLogger)
|
|
425
|
+
await eventLogger.info('Retention', `Applying retention rule: ${rule.name} to ${email.subject}`);
|
|
426
|
+
// We don't support custom drafts in retention yet (usually retention is for delete/archive)
|
|
427
|
+
await this.executeAction(account, email, rule.action, undefined, eventLogger, `Retention Rule: ${rule.name}`);
|
|
428
|
+
if (rule.action === 'delete')
|
|
429
|
+
result.deleted++;
|
|
430
|
+
else if (rule.action === 'draft')
|
|
431
|
+
result.drafted++;
|
|
432
|
+
break; // Only one rule per email
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
async executeAction(account, email, action, draftContent, eventLogger, reason, attachments) {
|
|
438
|
+
try {
|
|
439
|
+
if (eventLogger) {
|
|
440
|
+
await eventLogger.info('Acting', `Executing action: ${action}`, { reason, hasAttachments: !!attachments?.length }, email.id);
|
|
441
|
+
}
|
|
442
|
+
if (account.provider === 'gmail') {
|
|
443
|
+
if (action === 'delete') {
|
|
444
|
+
await this.gmailService.trashMessage(account, email.external_id);
|
|
445
|
+
}
|
|
446
|
+
else if (action === 'archive') {
|
|
447
|
+
await this.gmailService.archiveMessage(account, email.external_id);
|
|
448
|
+
}
|
|
449
|
+
else if (action === 'draft' && draftContent) {
|
|
450
|
+
const draftId = await this.gmailService.createDraft(account, email.external_id, draftContent, this.supabase, attachments);
|
|
451
|
+
if (eventLogger) {
|
|
452
|
+
await eventLogger.info('Drafted', `Draft created successfully. ID: ${draftId}`, { draftId }, email.id);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
else if (action === 'read') {
|
|
456
|
+
await this.gmailService.markAsRead(account, email.external_id);
|
|
457
|
+
}
|
|
458
|
+
else if (action === 'star') {
|
|
459
|
+
await this.gmailService.starMessage(account, email.external_id);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
else if (account.provider === 'outlook') {
|
|
463
|
+
if (action === 'delete') {
|
|
464
|
+
await this.microsoftService.trashMessage(account, email.external_id);
|
|
465
|
+
}
|
|
466
|
+
else if (action === 'archive') {
|
|
467
|
+
await this.microsoftService.archiveMessage(account, email.external_id);
|
|
468
|
+
}
|
|
469
|
+
else if (action === 'draft' && draftContent) {
|
|
470
|
+
await this.microsoftService.createDraft(account, email.external_id, draftContent);
|
|
471
|
+
}
|
|
472
|
+
else if (action === 'read') {
|
|
473
|
+
await this.microsoftService.markAsRead(account, email.external_id);
|
|
474
|
+
}
|
|
475
|
+
else if (action === 'star') {
|
|
476
|
+
await this.microsoftService.flagMessage(account, email.external_id);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
// Update email record using atomic array concatenation to prevent race conditions
|
|
480
|
+
await this.supabase.rpc('append_email_action', {
|
|
481
|
+
p_email_id: email.id,
|
|
482
|
+
p_action: action
|
|
483
|
+
});
|
|
484
|
+
// Fallback for legacy column
|
|
485
|
+
await this.supabase
|
|
486
|
+
.from('emails')
|
|
487
|
+
.update({ action_taken: action })
|
|
488
|
+
.eq('id', email.id);
|
|
489
|
+
logger.debug('Action executed', { emailId: email.id, action });
|
|
490
|
+
if (eventLogger) {
|
|
491
|
+
await eventLogger.action('Acted', email.id, action, reason);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
catch (error) {
|
|
495
|
+
logger.error('Failed to execute action', error, { emailId: email.id, action });
|
|
496
|
+
if (eventLogger) {
|
|
497
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
498
|
+
await eventLogger.error('Action Failed', { error: errMsg, action }, email.id);
|
|
499
|
+
}
|
|
500
|
+
// Do NOT throw here - we want to continue with other emails/actions
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|