@realtimex/email-automator 2.14.1 → 2.16.0

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.
@@ -69,8 +69,6 @@ router.post('/execute',
69
69
  }
70
70
  } else if (currentAction === 'flag') {
71
71
  await gmailService.addLabel(account, email.external_id, ['STARRED']);
72
- } else if (currentAction === 'read') {
73
- await gmailService.markAsRead(account, email.external_id);
74
72
  } else if (currentAction === 'star') {
75
73
  await gmailService.starMessage(account, email.external_id);
76
74
  }
@@ -87,8 +85,6 @@ router.post('/execute',
87
85
  const draftId = await microsoftService.createDraft(account, email.external_id, content);
88
86
  details = `Draft created: ${draftId}`;
89
87
  }
90
- } else if (currentAction === 'read') {
91
- await microsoftService.markAsRead(account, email.external_id);
92
88
  } else if (currentAction === 'star' || currentAction === 'flag') {
93
89
  await microsoftService.flagMessage(account, email.external_id);
94
90
  }
@@ -36,18 +36,24 @@ export const ContextAwareAnalysisSchema = z.object({
36
36
  summary: z.string().describe('A brief summary of the email content'),
37
37
  category: z.enum(['spam', 'newsletter', 'promotional', 'transactional', 'social', 'support', 'client', 'internal', 'personal', 'other'])
38
38
  .describe('The category of the email'),
39
+ sentiment: z.enum(['Positive', 'Neutral', 'Negative']).optional()
40
+ .describe('The emotional tone of the email'),
39
41
  priority: z.enum(['High', 'Medium', 'Low'])
40
42
  .describe('The urgency of the email'),
43
+ key_points: z.array(z.string()).optional()
44
+ .describe('Key points extracted from the email'),
45
+ language: z.string().optional()
46
+ .describe('The primary language of the email (e.g., "English", "Vietnamese", "Japanese")'),
41
47
 
42
- matched_rule: z.object({
43
- rule_id: z.string().nullable().describe('ID of the matched rule, or null if no match'),
44
- rule_name: z.string().nullable().describe('Name of the matched rule'),
48
+ matched_rules: z.array(z.object({
49
+ rule_id: z.string().describe('ID of the matched rule'),
50
+ rule_name: z.string().describe('Name of the matched rule'),
45
51
  confidence: z.number().min(0).max(1).describe('Confidence score for the match (0-1)'),
46
- reasoning: z.string().describe('Explanation of why this rule was matched or why no rule matched'),
47
- }),
52
+ reasoning: z.string().describe('Brief explanation of why this rule matched'),
53
+ })).describe('All rules that apply to this email (can be multiple)'),
48
54
 
49
- actions_to_execute: z.array(z.enum(['none', 'delete', 'archive', 'draft', 'read', 'star']))
50
- .describe('Actions to execute based on the matched rule'),
55
+ actions_to_execute: z.array(z.enum(['none', 'delete', 'archive', 'draft', 'star']))
56
+ .describe('Actions to execute after conflict resolution'),
51
57
 
52
58
  draft_content: z.string().nullable().optional()
53
59
  .describe('Generated draft reply if the action includes drafting'),
@@ -349,7 +355,7 @@ REQUIRED JSON STRUCTURE:
349
355
  rulesContext = compiledRulesContext.map(r => `- ${r.name}: ${r.intent}`).join('\n');
350
356
  }
351
357
 
352
- const systemPrompt = `You are an AI Automation Agent. Analyze the email and match it against the user's rules.
358
+ const systemPrompt = `You are an AI Automation Agent. Analyze the email and identify ALL rules that apply.
353
359
 
354
360
  Rules Context:
355
361
  ${rulesContext}
@@ -359,20 +365,25 @@ REQUIRED JSON STRUCTURE:
359
365
  "summary": "A brief summary of the email content",
360
366
  "category": "spam|newsletter|promotional|transactional|social|support|client|internal|personal|other",
361
367
  "priority": "High|Medium|Low",
362
- "matched_rule": {
363
- "rule_id": "string or null",
364
- "rule_name": "string or null",
365
- "confidence": 0.0 to 1.0,
366
- "reasoning": "Brief explanation"
367
- },
368
- "actions_to_execute": ["none"|"delete"|"archive"|"draft"|"read"|"star"],
368
+ "matched_rules": [
369
+ {
370
+ "rule_id": "string",
371
+ "rule_name": "string",
372
+ "confidence": 0.0 to 1.0,
373
+ "reasoning": "Brief explanation"
374
+ }
375
+ ],
376
+ "actions_to_execute": ["none"|"delete"|"archive"|"draft"|"star"],
369
377
  "draft_content": "Suggested reply if drafting, otherwise null"
370
378
  }
371
379
 
372
- IMPORTANT:
373
- - Use "draft" action only if a rule explicitly requests it or if it's very clear a reply is needed.
374
- - Categorize accurately.
375
- - Confidence 0.7+ is required for automatic execution.`;
380
+ CRITICAL INSTRUCTIONS:
381
+ - Identify ALL rules that apply to this email (not just the best one)
382
+ - Return an empty array if no rules match
383
+ - Only include rules with confidence >= 0.7
384
+ - For each matched rule, explain why it applies
385
+ - Actions will be merged by the system - you don't need to resolve conflicts
386
+ - Use "draft" action only if a rule explicitly requests it`;
376
387
 
377
388
  if (eventLogger) {
378
389
  await eventLogger.info('Thinking', `Context-aware analysis: ${context.subject}`, {
@@ -20,6 +20,111 @@ export interface ProcessingResult {
20
20
  errors: number;
21
21
  }
22
22
 
23
+ /**
24
+ * Action classification for conflict resolution
25
+ */
26
+ const ACTION_TYPES = {
27
+ // Exclusive: Only one can apply (highest priority wins)
28
+ EXCLUSIVE: ['delete', 'archive'] as const,
29
+
30
+ // Additive: All can apply (accumulate across rules)
31
+ ADDITIVE: ['star', 'unstar', 'important', 'pin'] as const,
32
+
33
+ // Semi-Exclusive: Only one makes sense, but non-conflicting
34
+ SEMI_EXCLUSIVE: ['draft'] as const
35
+ };
36
+
37
+ interface ResolvedActions {
38
+ exclusive?: string; // The winning exclusive action (delete or archive)
39
+ labels: string[]; // All labels to apply
40
+ additive: string[]; // Star, important, etc
41
+ draft?: {
42
+ content: string;
43
+ instructions: string;
44
+ attachments?: any[];
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Resolves conflicts between multiple matching rules
50
+ *
51
+ * Strategy:
52
+ * - EXCLUSIVE actions (delete, archive): Highest priority rule wins
53
+ * - ADDITIVE actions (labels, star): Apply ALL from ALL matching rules
54
+ * - SEMI-EXCLUSIVE (draft): Highest priority rule wins
55
+ * - If DELETE wins, skip all other actions (email is gone anyway)
56
+ *
57
+ * @param matchedRules - Rules sorted by priority (descending)
58
+ * @param allRules - Full rule objects for looking up actions
59
+ * @returns Resolved action set to execute
60
+ */
61
+ function resolveRuleConflicts(
62
+ matchedRules: Array<{ rule_id: string; confidence: number }>,
63
+ allRules: Rule[]
64
+ ): ResolvedActions {
65
+ const resolved: ResolvedActions = {
66
+ labels: [],
67
+ additive: []
68
+ };
69
+
70
+ // Process rules in priority order (already sorted)
71
+ for (const match of matchedRules) {
72
+ const rule = allRules.find(r => r.id === match.rule_id);
73
+ if (!rule) continue;
74
+
75
+ const actions = rule.actions || [];
76
+
77
+ for (const action of actions) {
78
+ // Handle label actions (always additive)
79
+ if (action.startsWith('label:')) {
80
+ const label = action.substring(6); // Remove 'label:' prefix
81
+ if (!resolved.labels.includes(label)) {
82
+ resolved.labels.push(label);
83
+ }
84
+ continue;
85
+ }
86
+
87
+ // Handle exclusive actions (delete/archive)
88
+ if (ACTION_TYPES.EXCLUSIVE.includes(action as any)) {
89
+ // Only set if not already set by higher priority rule
90
+ if (!resolved.exclusive) {
91
+ resolved.exclusive = action;
92
+ }
93
+ continue;
94
+ }
95
+
96
+ // Handle additive actions (star, important, etc)
97
+ if (ACTION_TYPES.ADDITIVE.includes(action as any)) {
98
+ if (!resolved.additive.includes(action)) {
99
+ resolved.additive.push(action);
100
+ }
101
+ continue;
102
+ }
103
+
104
+ // Handle draft (semi-exclusive)
105
+ if (action === 'draft' && !resolved.draft) {
106
+ resolved.draft = {
107
+ content: '', // Will be generated later
108
+ instructions: rule.instructions || '',
109
+ attachments: rule.attachments
110
+ };
111
+ continue;
112
+ }
113
+ }
114
+ }
115
+
116
+ // Nuclear option: If DELETE wins, discard everything else
117
+ if (resolved.exclusive === 'delete') {
118
+ return {
119
+ exclusive: 'delete',
120
+ labels: [],
121
+ additive: []
122
+ };
123
+ }
124
+
125
+ return resolved;
126
+ }
127
+
23
128
  export class EmailProcessorService {
24
129
  private supabase: SupabaseClient;
25
130
  private gmailService = getGmailService();
@@ -676,6 +781,7 @@ export class EmailProcessorService {
676
781
  }
677
782
 
678
783
  // 6. Update the email record with context-aware results
784
+ const primaryRule = analysis.matched_rules[0]; // Highest priority/confidence rule
679
785
  await this.supabase
680
786
  .from('emails')
681
787
  .update({
@@ -683,51 +789,147 @@ export class EmailProcessorService {
683
789
  ai_analysis: analysis as any,
684
790
  suggested_actions: analysis.actions_to_execute || [],
685
791
  suggested_action: analysis.actions_to_execute?.[0] || 'none',
686
- matched_rule_id: analysis.matched_rule.rule_id,
687
- matched_rule_confidence: analysis.matched_rule.confidence,
792
+ matched_rule_id: primaryRule?.rule_id || null,
793
+ matched_rule_confidence: primaryRule?.confidence || 0,
688
794
  processing_status: 'completed'
689
795
  })
690
796
  .eq('id', email.id);
691
797
 
692
- // 7. Execute actions if rule matched with sufficient confidence
693
- if (account && analysis.matched_rule.rule_id && analysis.matched_rule.confidence >= 0.7) {
694
- const matchedRule = rules?.find(r => r.id === analysis.matched_rule.rule_id);
798
+ // 7. Execute actions with conflict resolution
799
+ if (account && analysis.matched_rules.length > 0 && rules) {
800
+ // Filter rules by minimum confidence threshold
801
+ const highConfidenceMatches = analysis.matched_rules.filter(m => m.confidence >= 0.7);
802
+
803
+ if (highConfidenceMatches.length > 0) {
804
+ // Sort matched rules by their priority (from rules table)
805
+ const sortedMatches = highConfidenceMatches
806
+ .map(match => ({
807
+ ...match,
808
+ priority: rules.find(r => r.id === match.rule_id)?.priority || 0
809
+ }))
810
+ .sort((a, b) => b.priority - a.priority);
811
+
812
+ // Log all matched rules
813
+ if (eventLogger) {
814
+ const matchSummary = sortedMatches.map(m =>
815
+ `"${m.rule_name}" (${(m.confidence * 100).toFixed(0)}%)`
816
+ ).join(', ');
817
+ await eventLogger.info('Rules Matched',
818
+ `${sortedMatches.length} rule(s) apply: ${matchSummary}`,
819
+ { rules: sortedMatches.map(m => ({ name: m.rule_name, confidence: m.confidence, reasoning: m.reasoning })) },
820
+ email.id
821
+ );
822
+ }
695
823
 
696
- if (eventLogger) {
697
- await eventLogger.info('Rule Matched',
698
- `"${analysis.matched_rule.rule_name}" matched with ${(analysis.matched_rule.confidence * 100).toFixed(0)}% confidence`,
699
- { reasoning: analysis.matched_rule.reasoning },
700
- email.id
701
- );
702
- }
824
+ // Resolve conflicts between rules
825
+ const resolved = resolveRuleConflicts(sortedMatches, rules);
703
826
 
704
- // Execute each action from the AI's decision
705
- for (const action of analysis.actions_to_execute) {
706
- if (action === 'none') continue;
707
-
708
- // Use AI-generated draft content if available (handle null from AI)
709
- const draftContent = action === 'draft' ? (analysis.draft_content || undefined) : undefined;
710
-
711
- await this.executeAction(
712
- account,
713
- email,
714
- action as any,
715
- draftContent,
716
- eventLogger,
717
- `Rule: ${matchedRule?.name || analysis.matched_rule.rule_name}`,
718
- matchedRule?.attachments
719
- );
827
+ if (eventLogger) {
828
+ await eventLogger.info('Actions Resolved',
829
+ `After conflict resolution: ${[resolved.exclusive, ...resolved.labels.map(l => `label:${l}`), ...resolved.additive, resolved.draft ? 'draft' : null].filter(Boolean).join(', ')}`,
830
+ { resolved },
831
+ email.id
832
+ );
833
+ }
720
834
 
721
- // Update metrics if result object provided
722
- if (result) {
723
- if (action === 'delete') result.deleted++;
724
- else if (action === 'draft') result.drafted++;
835
+ // Execute exclusive action (delete or archive)
836
+ if (resolved.exclusive) {
837
+ await this.executeAction(
838
+ account,
839
+ email,
840
+ resolved.exclusive as any,
841
+ undefined,
842
+ eventLogger,
843
+ `Rules: ${sortedMatches.map(m => m.rule_name).join(', ')}`
844
+ );
845
+
846
+ if (result) {
847
+ if (resolved.exclusive === 'delete') result.deleted++;
848
+ }
725
849
  }
850
+
851
+ // If deleted, skip other actions (email is gone)
852
+ if (resolved.exclusive === 'delete') {
853
+ return;
854
+ }
855
+
856
+ // Execute label actions
857
+ for (const label of resolved.labels) {
858
+ await this.executeAction(
859
+ account,
860
+ email,
861
+ `label:${label}` as any,
862
+ undefined,
863
+ eventLogger,
864
+ `Rules: ${sortedMatches.map(m => m.rule_name).join(', ')}`
865
+ );
866
+ }
867
+
868
+ // Execute additive actions (star, important, etc)
869
+ for (const action of resolved.additive) {
870
+ await this.executeAction(
871
+ account,
872
+ email,
873
+ action as any,
874
+ undefined,
875
+ eventLogger,
876
+ `Rules: ${sortedMatches.map(m => m.rule_name).join(', ')}`
877
+ );
878
+ }
879
+
880
+ // Execute draft action
881
+ if (resolved.draft) {
882
+ // Build rich context for draft generation
883
+ const emailDomain = account.email_address?.split('@')[1] || undefined;
884
+ const richContext = {
885
+ myEmail: account.email_address,
886
+ myName: undefined,
887
+ myRole: settings?.user_role || undefined,
888
+ myCompany: emailDomain,
889
+ category: analysis?.category,
890
+ sentiment: analysis?.sentiment,
891
+ priority: analysis?.priority,
892
+ keyPoints: analysis?.key_points,
893
+ language: analysis?.language,
894
+ senderEmail: email.sender || undefined,
895
+ senderName: email.sender || undefined,
896
+ receivedDate: email.date ? new Date(email.date) : undefined
897
+ };
898
+
899
+ const draftContent = await intelligenceService.generateDraftReply({
900
+ subject: email.subject || '',
901
+ sender: email.sender || '',
902
+ body: email.body_snippet || ''
903
+ }, resolved.draft.instructions, {
904
+ llm_provider: settings?.llm_provider,
905
+ llm_model: settings?.llm_model
906
+ }, richContext);
907
+
908
+ if (draftContent) {
909
+ await this.executeAction(
910
+ account,
911
+ email,
912
+ 'draft' as any,
913
+ draftContent,
914
+ eventLogger,
915
+ `Rules: ${sortedMatches.map(m => m.rule_name).join(', ')}`,
916
+ resolved.draft.attachments
917
+ );
918
+
919
+ if (result) result.drafted++;
920
+ }
921
+ }
922
+ } else if (eventLogger) {
923
+ await eventLogger.info('Low Confidence',
924
+ `${analysis.matched_rules.length} rule(s) matched but below 0.7 confidence threshold`,
925
+ { rules: analysis.matched_rules },
926
+ email.id
927
+ );
726
928
  }
727
929
  } else if (eventLogger && rules && rules.length > 0) {
728
930
  await eventLogger.info('No Match',
729
- analysis.matched_rule.reasoning,
730
- { confidence: analysis.matched_rule.confidence },
931
+ 'No rules matched this email',
932
+ { category: analysis.category },
731
933
  email.id
732
934
  );
733
935
  }
@@ -1089,8 +1291,6 @@ export class EmailProcessorService {
1089
1291
  if (eventLogger) {
1090
1292
  await eventLogger.info('Drafted', `Draft created successfully. ID: ${draftId}`, { draftId }, email.id);
1091
1293
  }
1092
- } else if (action === 'read') {
1093
- await this.gmailService.markAsRead(account, email.external_id);
1094
1294
  } else if (action === 'star') {
1095
1295
  await this.gmailService.starMessage(account, email.external_id);
1096
1296
  } else if (action === 'important') {
@@ -1108,8 +1308,6 @@ export class EmailProcessorService {
1108
1308
  await this.microsoftService.archiveMessage(account, email.external_id);
1109
1309
  } else if (action === 'draft' && draftContent) {
1110
1310
  await this.microsoftService.createDraft(account, email.external_id, draftContent);
1111
- } else if (action === 'read') {
1112
- await this.microsoftService.markAsRead(account, email.external_id);
1113
1311
  } else if (action === 'star' || action === 'important') {
1114
1312
  await this.microsoftService.flagMessage(account, email.external_id);
1115
1313
  }
@@ -73,7 +73,7 @@ export const DEVELOPER_PACK: RulePack = {
73
73
  }
74
74
  ]
75
75
  },
76
- actions: ['label:Logs', 'archive', 'read'],
76
+ actions: ['label:Logs', 'archive'],
77
77
  priority: 20,
78
78
  is_enabled_by_default: true
79
79
  },
@@ -120,7 +120,7 @@ export const EXECUTIVE_PACK: RulePack = {
120
120
  category: 'social',
121
121
  confidence_gt: 0.8
122
122
  },
123
- actions: ['archive', 'read'],
123
+ actions: ['archive'],
124
124
  priority: 5,
125
125
  is_enabled_by_default: true
126
126
  },
@@ -21,7 +21,6 @@ export type EmailAction =
21
21
  | 'delete'
22
22
  | 'archive'
23
23
  | 'draft'
24
- | 'read'
25
24
  | 'star'
26
25
  | 'unstar'
27
26
  | 'important'
@@ -24,7 +24,7 @@ export const UNIVERSAL_PACK: RulePack = {
24
24
  category: 'newsletter',
25
25
  confidence_gt: 0.7
26
26
  },
27
- actions: ['archive', 'read'],
27
+ actions: ['archive'],
28
28
  priority: 10,
29
29
  is_enabled_by_default: true
30
30
  },
@@ -59,9 +59,6 @@ router.post('/execute', apiRateLimit, authMiddleware, validateBody(schemas.execu
59
59
  else if (currentAction === 'flag') {
60
60
  await gmailService.addLabel(account, email.external_id, ['STARRED']);
61
61
  }
62
- else if (currentAction === 'read') {
63
- await gmailService.markAsRead(account, email.external_id);
64
- }
65
62
  else if (currentAction === 'star') {
66
63
  await gmailService.starMessage(account, email.external_id);
67
64
  }
@@ -81,9 +78,6 @@ router.post('/execute', apiRateLimit, authMiddleware, validateBody(schemas.execu
81
78
  details = `Draft created: ${draftId}`;
82
79
  }
83
80
  }
84
- else if (currentAction === 'read') {
85
- await microsoftService.markAsRead(account, email.external_id);
86
- }
87
81
  else if (currentAction === 'star' || currentAction === 'flag') {
88
82
  await microsoftService.flagMessage(account, email.external_id);
89
83
  }
@@ -30,16 +30,22 @@ export const ContextAwareAnalysisSchema = z.object({
30
30
  summary: z.string().describe('A brief summary of the email content'),
31
31
  category: z.enum(['spam', 'newsletter', 'promotional', 'transactional', 'social', 'support', 'client', 'internal', 'personal', 'other'])
32
32
  .describe('The category of the email'),
33
+ sentiment: z.enum(['Positive', 'Neutral', 'Negative']).optional()
34
+ .describe('The emotional tone of the email'),
33
35
  priority: z.enum(['High', 'Medium', 'Low'])
34
36
  .describe('The urgency of the email'),
35
- matched_rule: z.object({
36
- rule_id: z.string().nullable().describe('ID of the matched rule, or null if no match'),
37
- rule_name: z.string().nullable().describe('Name of the matched rule'),
37
+ key_points: z.array(z.string()).optional()
38
+ .describe('Key points extracted from the email'),
39
+ language: z.string().optional()
40
+ .describe('The primary language of the email (e.g., "English", "Vietnamese", "Japanese")'),
41
+ matched_rules: z.array(z.object({
42
+ rule_id: z.string().describe('ID of the matched rule'),
43
+ rule_name: z.string().describe('Name of the matched rule'),
38
44
  confidence: z.number().min(0).max(1).describe('Confidence score for the match (0-1)'),
39
- reasoning: z.string().describe('Explanation of why this rule was matched or why no rule matched'),
40
- }),
41
- actions_to_execute: z.array(z.enum(['none', 'delete', 'archive', 'draft', 'read', 'star']))
42
- .describe('Actions to execute based on the matched rule'),
45
+ reasoning: z.string().describe('Brief explanation of why this rule matched'),
46
+ })).describe('All rules that apply to this email (can be multiple)'),
47
+ actions_to_execute: z.array(z.enum(['none', 'delete', 'archive', 'draft', 'star']))
48
+ .describe('Actions to execute after conflict resolution'),
43
49
  draft_content: z.string().nullable().optional()
44
50
  .describe('Generated draft reply if the action includes drafting'),
45
51
  });
@@ -258,7 +264,7 @@ REQUIRED JSON STRUCTURE:
258
264
  else {
259
265
  rulesContext = compiledRulesContext.map(r => `- ${r.name}: ${r.intent}`).join('\n');
260
266
  }
261
- const systemPrompt = `You are an AI Automation Agent. Analyze the email and match it against the user's rules.
267
+ const systemPrompt = `You are an AI Automation Agent. Analyze the email and identify ALL rules that apply.
262
268
 
263
269
  Rules Context:
264
270
  ${rulesContext}
@@ -268,20 +274,25 @@ REQUIRED JSON STRUCTURE:
268
274
  "summary": "A brief summary of the email content",
269
275
  "category": "spam|newsletter|promotional|transactional|social|support|client|internal|personal|other",
270
276
  "priority": "High|Medium|Low",
271
- "matched_rule": {
272
- "rule_id": "string or null",
273
- "rule_name": "string or null",
274
- "confidence": 0.0 to 1.0,
275
- "reasoning": "Brief explanation"
276
- },
277
- "actions_to_execute": ["none"|"delete"|"archive"|"draft"|"read"|"star"],
277
+ "matched_rules": [
278
+ {
279
+ "rule_id": "string",
280
+ "rule_name": "string",
281
+ "confidence": 0.0 to 1.0,
282
+ "reasoning": "Brief explanation"
283
+ }
284
+ ],
285
+ "actions_to_execute": ["none"|"delete"|"archive"|"draft"|"star"],
278
286
  "draft_content": "Suggested reply if drafting, otherwise null"
279
287
  }
280
288
 
281
- IMPORTANT:
282
- - Use "draft" action only if a rule explicitly requests it or if it's very clear a reply is needed.
283
- - Categorize accurately.
284
- - Confidence 0.7+ is required for automatic execution.`;
289
+ CRITICAL INSTRUCTIONS:
290
+ - Identify ALL rules that apply to this email (not just the best one)
291
+ - Return an empty array if no rules match
292
+ - Only include rules with confidence >= 0.7
293
+ - For each matched rule, explain why it applies
294
+ - Actions will be merged by the system - you don't need to resolve conflicts
295
+ - Use "draft" action only if a rule explicitly requests it`;
285
296
  if (eventLogger) {
286
297
  await eventLogger.info('Thinking', `Context-aware analysis: ${context.subject}`, {
287
298
  provider: `${provider}/${model}`,