@realtimex/email-automator 2.6.2 → 2.6.5

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.
@@ -87,8 +87,12 @@ export const schemas = {
87
87
  }),
88
88
 
89
89
  // Rule schemas - supports both single action (legacy) and actions array
90
+ // Now includes description and intent for context-aware AI matching
90
91
  createRule: z.object({
91
92
  name: z.string().min(1).max(100),
93
+ description: z.string().max(500).optional(),
94
+ intent: z.string().max(200).optional(),
95
+ priority: z.number().int().min(0).max(100).optional(),
92
96
  condition: z.record(z.unknown()),
93
97
  action: z.enum(['delete', 'archive', 'draft', 'star', 'read']).optional(),
94
98
  actions: z.array(z.enum(['delete', 'archive', 'draft', 'star', 'read'])).optional(),
@@ -100,6 +104,9 @@ export const schemas = {
100
104
 
101
105
  updateRule: z.object({
102
106
  name: z.string().min(1).max(100).optional(),
107
+ description: z.string().max(500).optional(),
108
+ intent: z.string().max(200).optional(),
109
+ priority: z.number().int().min(0).max(100).optional(),
103
110
  condition: z.record(z.unknown()).optional(),
104
111
  action: z.enum(['delete', 'archive', 'draft', 'star', 'read']).optional(),
105
112
  actions: z.array(z.enum(['delete', 'archive', 'draft', 'star', 'read'])).optional(),
@@ -68,7 +68,12 @@ router.patch('/:ruleId',
68
68
  validateBody(schemas.updateRule),
69
69
  asyncHandler(async (req, res) => {
70
70
  const { ruleId } = req.params;
71
- const updates = req.body;
71
+ const updates = { ...req.body };
72
+
73
+ // Ensure legacy action is in sync if actions array is provided
74
+ if (updates.actions && Array.isArray(updates.actions) && updates.actions.length > 0) {
75
+ updates.action = updates.actions[0];
76
+ }
72
77
 
73
78
  const { data, error } = await req.supabase!
74
79
  .from('rules')
@@ -81,7 +86,7 @@ router.patch('/:ruleId',
81
86
  if (error) throw error;
82
87
  if (!data) throw new NotFoundError('Rule');
83
88
 
84
- logger.info('Rule updated', { ruleId, userId: req.user!.id });
89
+ logger.info('Rule updated', { ruleId, actions: data.actions, userId: req.user!.id });
85
90
 
86
91
  res.json({ rule: data });
87
92
  })
@@ -30,6 +30,44 @@ export const EmailAnalysisSchema = z.object({
30
30
 
31
31
  export type EmailAnalysis = z.infer<typeof EmailAnalysisSchema>;
32
32
 
33
+ // Context-Aware Analysis Schema - AI evaluates email against user's rules
34
+ export const ContextAwareAnalysisSchema = z.object({
35
+ // Classification (kept for UI/logging)
36
+ summary: z.string().describe('A brief summary of the email content'),
37
+ category: z.enum(['spam', 'newsletter', 'promotional', 'transactional', 'social', 'support', 'client', 'internal', 'personal', 'other'])
38
+ .describe('The category of the email'),
39
+ priority: z.enum(['High', 'Medium', 'Low'])
40
+ .describe('The urgency of the email'),
41
+
42
+ // Rule Matching (core of context-aware engine)
43
+ matched_rule: z.object({
44
+ rule_id: z.string().nullable().describe('ID of the matched rule, or null if no match'),
45
+ rule_name: z.string().nullable().describe('Name of the matched rule'),
46
+ confidence: z.number().min(0).max(1).describe('Confidence score for the match (0-1)'),
47
+ reasoning: z.string().describe('Explanation of why this rule was matched or why no rule matched'),
48
+ }),
49
+
50
+ // Actions to execute (derived from matched rule)
51
+ actions_to_execute: z.array(z.enum(['none', 'delete', 'archive', 'draft', 'read', 'star']))
52
+ .describe('Actions to execute based on the matched rule'),
53
+
54
+ // Intent-aware draft content (if draft action is included)
55
+ draft_content: z.string().optional()
56
+ .describe('Generated draft reply if the action includes drafting'),
57
+ });
58
+
59
+ export type ContextAwareAnalysis = z.infer<typeof ContextAwareAnalysisSchema>;
60
+
61
+ // Rule context for AI matching
62
+ export interface RuleContext {
63
+ id: string;
64
+ name: string;
65
+ description?: string;
66
+ intent?: string;
67
+ actions: string[];
68
+ draft_instructions?: string;
69
+ }
70
+
33
71
  export interface EmailContext {
34
72
  subject: string;
35
73
  sender: string;
@@ -87,7 +125,7 @@ export class IntelligenceService {
87
125
 
88
126
  async analyzeEmail(content: string, context: EmailContext, eventLogger?: EventLogger, emailId?: string): Promise<EmailAnalysis | null> {
89
127
  console.log('[Intelligence] analyzeEmail called for:', context.subject);
90
-
128
+
91
129
  if (!this.isReady()) {
92
130
  console.log('[Intelligence] Not ready, skipping');
93
131
  logger.warn('Intelligence service not ready, skipping analysis');
@@ -99,7 +137,7 @@ export class IntelligenceService {
99
137
 
100
138
  // 1. Prepare Content and Signals
101
139
  const cleanedContent = ContentCleaner.cleanEmailBody(content).substring(0, 2500);
102
-
140
+
103
141
  const metadataSignals = [];
104
142
  if (context.metadata?.listUnsubscribe) metadataSignals.push('- Contains Unsubscribe header (High signal for Newsletter/Promo)');
105
143
  if (context.metadata?.autoSubmitted && context.metadata.autoSubmitted !== 'no') metadataSignals.push(`- Auto-Submitted: ${context.metadata.autoSubmitted}`);
@@ -148,7 +186,7 @@ REQUIRED JSON STRUCTURE:
148
186
  if (eventLogger) {
149
187
  console.log('[Intelligence] Logging "Thinking" event');
150
188
  try {
151
- await eventLogger.info('Thinking', `Analyzing email: ${context.subject}`, {
189
+ await eventLogger.info('Thinking', `Analyzing email: ${context.subject}`, {
152
190
  model: this.model,
153
191
  system_prompt: systemPrompt,
154
192
  content_preview: cleanedContent,
@@ -174,12 +212,12 @@ REQUIRED JSON STRUCTURE:
174
212
 
175
213
  rawResponse = response.choices[0]?.message?.content || '';
176
214
  console.log('[Intelligence] Raw LLM Response received (length:', rawResponse.length, ')');
177
-
215
+
178
216
  // Clean the response: Find first '{' and last '}'
179
217
  let jsonStr = rawResponse.trim();
180
218
  const startIdx = jsonStr.indexOf('{');
181
219
  const endIdx = jsonStr.lastIndexOf('}');
182
-
220
+
183
221
  if (startIdx === -1 || endIdx === -1) {
184
222
  throw new Error('Response did not contain a valid JSON object (missing curly braces)');
185
223
  }
@@ -197,7 +235,7 @@ REQUIRED JSON STRUCTURE:
197
235
  if (eventLogger && emailId) {
198
236
  await eventLogger.analysis('Decided', emailId, {
199
237
  ...validated,
200
- _raw_response: rawResponse
238
+ _raw_response: rawResponse
201
239
  });
202
240
  }
203
241
 
@@ -251,6 +289,189 @@ Please write a reply.`,
251
289
  }
252
290
  }
253
291
 
292
+ /**
293
+ * Context-Aware Analysis: AI evaluates email against user's rules semantically
294
+ * This is the core of the new automation engine
295
+ *
296
+ * @param compiledRulesContext - Pre-compiled rules context string (from user_settings.compiled_rule_context)
297
+ * OR RuleContext[] for backwards compatibility
298
+ */
299
+ async analyzeEmailWithRules(
300
+ content: string,
301
+ context: EmailContext,
302
+ compiledRulesContext: string | RuleContext[],
303
+ eventLogger?: EventLogger,
304
+ emailId?: string
305
+ ): Promise<ContextAwareAnalysis | null> {
306
+ console.log('[Intelligence] analyzeEmailWithRules called for:', context.subject);
307
+
308
+ if (!this.isReady()) {
309
+ console.log('[Intelligence] Not ready, skipping');
310
+ logger.warn('Intelligence service not ready, skipping analysis');
311
+ if (eventLogger) {
312
+ await eventLogger.info('Skipped', 'AI Analysis skipped: Model not configured.', undefined, emailId);
313
+ }
314
+ return null;
315
+ }
316
+
317
+ // Prepare content
318
+ const cleanedContent = ContentCleaner.cleanEmailBody(content).substring(0, 2500);
319
+
320
+ // Use pre-compiled context if string, otherwise build from RuleContext[] (backwards compat)
321
+ let rulesContext: string;
322
+ let rulesCount: number;
323
+
324
+ if (typeof compiledRulesContext === 'string') {
325
+ // Fast path: use pre-compiled context
326
+ rulesContext = compiledRulesContext || '\n[No rules defined - analyze email but take no actions]\n';
327
+ rulesCount = (rulesContext.match(/Rule \d+/g) || []).length;
328
+ } else {
329
+ // Backwards compatibility: build from RuleContext[]
330
+ const rules = compiledRulesContext;
331
+ rulesCount = rules.length;
332
+ rulesContext = rules.length > 0
333
+ ? rules.map((r, i) => `
334
+ ### Rule ${i + 1}: "${r.name}" (ID: ${r.id})
335
+ - Description: ${r.description || 'No description provided'}
336
+ - Intent: ${r.intent || 'General automation'}
337
+ - Actions: ${r.actions.join(', ')}
338
+ ${r.draft_instructions ? `- Draft Instructions: "${r.draft_instructions}"` : ''}
339
+ `).join('\n')
340
+ : '\n[No rules defined - analyze email but take no actions]\n';
341
+ }
342
+
343
+ const systemPrompt = `You are an AI Email Automation Agent.
344
+
345
+ ## Your Operating Rules
346
+ The user has defined the following automation rules. Your job is to:
347
+ 1. Analyze the incoming email
348
+ 2. Determine if ANY rule semantically matches this email's context
349
+ 3. Match based on INTENT, not just keywords
350
+
351
+ ${rulesContext}
352
+
353
+ ## Category Definitions (choose the most accurate)
354
+ - **client**: Business inquiries, RFPs, quote requests, project discussions, potential customers reaching out
355
+ - **support**: Help requests, bug reports, technical questions from existing users
356
+ - **internal**: Messages from colleagues, team communications
357
+ - **transactional**: Receipts, confirmations, shipping updates, account notifications
358
+ - **newsletter**: Subscribed content, digests, updates from services you signed up for
359
+ - **promotional**: UNSOLICITED marketing, cold sales pitches, ads - NOT legitimate business inquiries
360
+ - **spam**: Scams, phishing, junk mail
361
+ - **social**: Social media notifications, friend requests
362
+ - **personal**: Friends, family, personal matters
363
+ - **other**: Anything that doesn't fit above
364
+
365
+ ## Matching Guidelines
366
+ - A "decline sales" rule should match ANY sales pitch, not just ones with "sales" in the subject
367
+ - Match the rule that best fits the USER'S INTENT
368
+ - Only match if you are confident (>= 0.7 confidence)
369
+ - If no rule clearly matches, return null for rule_id
370
+ - If a matched rule includes "draft" action, generate an appropriate draft using the rule's intent
371
+
372
+ ## CRITICAL: Distinguish Between Inbound vs Outbound
373
+ **INBOUND (Client Inquiries - NOT promotional):**
374
+ - User is RECEIVING a request for quote/proposal/service
375
+ - Examples: "Please send me a quote", "RFP: [project]", "Can you provide pricing", "I need a quote asap"
376
+ - Category: client, support, or transactional (NEVER promotional)
377
+
378
+ **OUTBOUND (Sales/Marketing - IS promotional):**
379
+ - User is RECEIVING a sales pitch or marketing message
380
+ - Examples: "Get a FREE quote today!", "Limited offer", "Don't miss out", "Special discount"
381
+ - Category: promotional, spam, or newsletter
382
+
383
+ **Key Distinction:** If someone is ASKING the user for something (quote, proposal, service), it's a CLIENT INQUIRY, not promotional content.
384
+
385
+ ## Email Context
386
+ - Current Date: ${new Date().toISOString()}
387
+ - Subject: ${context.subject}
388
+ - From: ${context.sender}
389
+ - Date: ${context.date}
390
+
391
+ ## Required JSON Response
392
+ {
393
+ "summary": "Brief summary of the email",
394
+ "category": "spam|newsletter|promotional|transactional|social|support|client|internal|personal|other",
395
+ "priority": "High|Medium|Low",
396
+ "matched_rule": {
397
+ "rule_id": "UUID or null",
398
+ "rule_name": "Rule name or null",
399
+ "confidence": 0.0-1.0,
400
+ "reasoning": "Why this rule was or wasn't matched"
401
+ },
402
+ "actions_to_execute": ["none"] or ["archive", "read", etc.],
403
+ "draft_content": "Optional: draft reply if action includes 'draft'"
404
+ }
405
+
406
+ Return ONLY valid JSON.`;
407
+
408
+ // Log thinking phase
409
+ if (eventLogger) {
410
+ try {
411
+ await eventLogger.info('Thinking', `Context-aware analysis: ${context.subject}`, {
412
+ model: this.model,
413
+ system_prompt: systemPrompt,
414
+ content_preview: cleanedContent,
415
+ rules_count: rulesCount,
416
+ }, emailId);
417
+ } catch (err) {
418
+ console.error('[Intelligence] Failed to log thinking event:', err);
419
+ }
420
+ }
421
+
422
+ let rawResponse = '';
423
+ try {
424
+ const response = await this.client!.chat.completions.create({
425
+ model: this.model,
426
+ messages: [
427
+ { role: 'system', content: systemPrompt },
428
+ { role: 'user', content: cleanedContent || '[Empty email body]' },
429
+ ],
430
+ temperature: 0.1,
431
+ });
432
+
433
+ rawResponse = response.choices[0]?.message?.content || '';
434
+ console.log('[Intelligence] Context-aware response received (length:', rawResponse.length, ')');
435
+
436
+ // Parse JSON from response
437
+ let jsonStr = rawResponse.trim();
438
+ const startIdx = jsonStr.indexOf('{');
439
+ const endIdx = jsonStr.lastIndexOf('}');
440
+
441
+ if (startIdx === -1 || endIdx === -1) {
442
+ throw new Error('Response did not contain a valid JSON object');
443
+ }
444
+
445
+ jsonStr = jsonStr.substring(startIdx, endIdx + 1);
446
+ const parsed = JSON.parse(jsonStr);
447
+ const validated = ContextAwareAnalysisSchema.parse(parsed);
448
+
449
+ logger.debug('Context-aware analysis complete', {
450
+ matched_rule: validated.matched_rule.rule_name,
451
+ confidence: validated.matched_rule.confidence,
452
+ actions: validated.actions_to_execute,
453
+ });
454
+
455
+ if (eventLogger && emailId) {
456
+ await eventLogger.analysis('Decided', emailId, {
457
+ ...validated,
458
+ _raw_response: rawResponse
459
+ });
460
+ }
461
+
462
+ return validated;
463
+ } catch (error) {
464
+ console.error('[Intelligence] Context-aware analysis failed:', error);
465
+ if (eventLogger) {
466
+ await eventLogger.error('Error', {
467
+ error: error instanceof Error ? error.message : String(error),
468
+ raw_response: rawResponse || 'No response received from LLM'
469
+ }, emailId);
470
+ }
471
+ return null;
472
+ }
473
+ }
474
+
254
475
  async testConnection(): Promise<{ success: boolean; message: string }> {
255
476
  if (!this.isReady()) {
256
477
  return { success: false, message: 'Intelligence service not initialized. Check your API Key.' };
@@ -4,7 +4,7 @@ import { createLogger } from '../utils/logger.js';
4
4
  import { config } from '../config/index.js';
5
5
  import { getGmailService, GmailMessage } from './gmail.js';
6
6
  import { getMicrosoftService, OutlookMessage } from './microsoft.js';
7
- import { getIntelligenceService, EmailAnalysis } from './intelligence.js';
7
+ import { getIntelligenceService, EmailAnalysis, ContextAwareAnalysis, RuleContext } from './intelligence.js';
8
8
  import { getStorageService } from './storage.js';
9
9
  import { EmailAccount, Email, Rule, ProcessingLog } from './supabase.js';
10
10
  import { EventLogger } from './eventLogger.js';
@@ -551,7 +551,39 @@ export class EmailProcessorService {
551
551
  mailer: parsed.headers.get('x-mailer')?.toString()
552
552
  };
553
553
 
554
- // 3. Analyze with AI
554
+ // 3. Fetch account for action execution
555
+ const { data: account } = await this.supabase
556
+ .from('email_accounts')
557
+ .select('*')
558
+ .eq('id', email.account_id)
559
+ .single();
560
+
561
+ // 4. Fetch pre-compiled rule context (fast path - no loop/formatting)
562
+ // Falls back to building context if not cached
563
+ let compiledContext: string | null = settings?.compiled_rule_context || null;
564
+
565
+ // Fetch rules for action execution (need attachments, instructions)
566
+ const { data: rules } = await this.supabase
567
+ .from('rules')
568
+ .select('*')
569
+ .eq('user_id', userId)
570
+ .eq('is_enabled', true)
571
+ .order('priority', { ascending: false });
572
+
573
+ // Fallback: build context if not pre-compiled
574
+ if (!compiledContext && rules && rules.length > 0) {
575
+ compiledContext = rules.map((r, i) =>
576
+ `Rule ${i + 1} [ID: ${r.id}]\n` +
577
+ ` Name: ${r.name}\n` +
578
+ (r.description ? ` Description: ${r.description}\n` : '') +
579
+ (r.intent ? ` Intent: ${r.intent}\n` : '') +
580
+ ` Actions: ${r.actions?.join(', ') || r.action || 'none'}\n` +
581
+ (r.instructions ? ` Draft Instructions: ${r.instructions}\n` : '') +
582
+ '\n'
583
+ ).join('');
584
+ }
585
+
586
+ // 5. Context-Aware Analysis: AI evaluates email against user's rules
555
587
  const intelligenceService = getIntelligenceService(
556
588
  settings?.llm_model || settings?.llm_base_url || settings?.llm_api_key
557
589
  ? {
@@ -562,53 +594,76 @@ export class EmailProcessorService {
562
594
  : undefined
563
595
  );
564
596
 
565
- const analysis = await intelligenceService.analyzeEmail(cleanContent, {
566
- subject: email.subject || '',
567
- sender: email.sender || '',
568
- date: email.date || '',
569
- metadata,
570
- userPreferences: {
571
- autoTrashSpam: settings?.auto_trash_spam,
572
- smartDrafts: settings?.smart_drafts,
597
+ const analysis = await intelligenceService.analyzeEmailWithRules(
598
+ cleanContent,
599
+ {
600
+ subject: email.subject || '',
601
+ sender: email.sender || '',
602
+ date: email.date || '',
603
+ metadata,
604
+ userPreferences: {
605
+ autoTrashSpam: settings?.auto_trash_spam,
606
+ smartDrafts: settings?.smart_drafts,
607
+ },
573
608
  },
574
- }, eventLogger || undefined, email.id);
609
+ compiledContext || '', // Pre-compiled context (fast path)
610
+ eventLogger || undefined,
611
+ email.id
612
+ );
575
613
 
576
614
  if (!analysis) {
577
615
  throw new Error('AI analysis returned no result');
578
616
  }
579
617
 
580
- // 4. Update the email record with results
618
+ // 6. Update the email record with context-aware results
581
619
  await this.supabase
582
620
  .from('emails')
583
621
  .update({
584
622
  category: analysis.category,
585
- is_useless: analysis.is_useless,
586
623
  ai_analysis: analysis as any,
587
- suggested_actions: analysis.suggested_actions || [],
588
- suggested_action: analysis.suggested_actions?.[0] || 'none',
624
+ suggested_actions: analysis.actions_to_execute || [],
625
+ suggested_action: analysis.actions_to_execute?.[0] || 'none',
626
+ matched_rule_id: analysis.matched_rule.rule_id,
627
+ matched_rule_confidence: analysis.matched_rule.confidence,
589
628
  processing_status: 'completed'
590
629
  })
591
630
  .eq('id', email.id);
592
631
 
593
- // 5. Execute automation rules
594
- // Fetch account and rules needed for execution
595
- const { data: account } = await this.supabase
596
- .from('email_accounts')
597
- .select('*')
598
- .eq('id', email.account_id)
599
- .single();
600
-
601
- const { data: rules } = await this.supabase
602
- .from('rules')
603
- .select('*')
604
- .eq('user_id', userId)
605
- .eq('is_enabled', true);
632
+ // 7. Execute actions if rule matched with sufficient confidence
633
+ if (account && analysis.matched_rule.rule_id && analysis.matched_rule.confidence >= 0.7) {
634
+ const matchedRule = rules?.find(r => r.id === analysis.matched_rule.rule_id);
635
+
636
+ if (eventLogger) {
637
+ await eventLogger.info('Rule Matched',
638
+ `"${analysis.matched_rule.rule_name}" matched with ${(analysis.matched_rule.confidence * 100).toFixed(0)}% confidence`,
639
+ { reasoning: analysis.matched_rule.reasoning },
640
+ email.id
641
+ );
642
+ }
606
643
 
607
- if (account && rules) {
608
- const tempResult = { processed: 0, deleted: 0, drafted: 0, errors: 0 };
609
- // Ensure email object for rules has the analysis fields merged in
610
- const emailForRules = { ...email, ...analysis };
611
- await this.executeRules(account, emailForRules as any, analysis, rules, settings, tempResult, eventLogger);
644
+ // Execute each action from the AI's decision
645
+ for (const action of analysis.actions_to_execute) {
646
+ if (action === 'none') continue;
647
+
648
+ // Use AI-generated draft content if available
649
+ const draftContent = action === 'draft' ? analysis.draft_content : undefined;
650
+
651
+ await this.executeAction(
652
+ account,
653
+ email,
654
+ action as any,
655
+ draftContent,
656
+ eventLogger,
657
+ `Rule: ${matchedRule?.name || analysis.matched_rule.rule_name}`,
658
+ matchedRule?.attachments
659
+ );
660
+ }
661
+ } else if (eventLogger && rules && rules.length > 0) {
662
+ await eventLogger.info('No Match',
663
+ analysis.matched_rule.reasoning,
664
+ { confidence: analysis.matched_rule.confidence },
665
+ email.id
666
+ );
612
667
  }
613
668
 
614
669
  // Mark log as success
@@ -115,10 +115,13 @@ export interface Rule {
115
115
  id: string;
116
116
  user_id: string;
117
117
  name: string;
118
- condition: Record<string, unknown>;
118
+ description?: string; // Semantic context for AI matching
119
+ intent?: string; // The intent behind the rule (e.g., "Politely decline sales pitches")
120
+ priority?: number; // Higher = evaluated first by AI
121
+ condition: Record<string, unknown>; // Legacy - kept for backwards compatibility
119
122
  action?: 'delete' | 'archive' | 'draft' | 'star' | 'read'; // Legacy single action
120
123
  actions?: ('delete' | 'archive' | 'draft' | 'star' | 'read')[]; // New multi-action array
121
- instructions?: string;
124
+ instructions?: string; // Draft generation instructions
122
125
  attachments?: any[];
123
126
  is_enabled: boolean;
124
127
  created_at: string;