@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.
@@ -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
+ }