@realtimex/email-automator 2.23.10 → 2.24.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.
Files changed (31) hide show
  1. package/api/src/middleware/validation.ts +21 -4
  2. package/api/src/routes/index.ts +2 -0
  3. package/api/src/routes/learning.ts +99 -0
  4. package/api/src/services/defaultRules/emailOrg.ts +1 -1
  5. package/api/src/services/defaultRules/types.ts +5 -0
  6. package/api/src/services/intelligence.ts +126 -4
  7. package/api/src/services/learning.ts +16 -4
  8. package/api/src/services/processor.ts +384 -78
  9. package/api/src/services/supabase.ts +13 -1
  10. package/api/src/utils/emailHeaders.ts +292 -0
  11. package/dist/api/src/middleware/validation.js +21 -4
  12. package/dist/api/src/routes/index.js +2 -0
  13. package/dist/api/src/routes/learning.js +86 -0
  14. package/dist/api/src/services/defaultRules/emailOrg.js +1 -1
  15. package/dist/api/src/services/intelligence.js +140 -4
  16. package/dist/api/src/services/learning.js +11 -4
  17. package/dist/api/src/services/processor.js +488 -202
  18. package/dist/api/src/utils/emailHeaders.js +238 -0
  19. package/dist/assets/index-FRMyxVih.js +217 -0
  20. package/dist/assets/index-otwjpYTB.css +1 -0
  21. package/dist/index.html +2 -2
  22. package/package.json +1 -1
  23. package/supabase/migrations/20260206000012_add_email_headers.sql +29 -0
  24. package/supabase/migrations/20260206000013_add_negative_conditions.sql +15 -0
  25. package/supabase/migrations/20260206000014_add_rule_confidence.sql +15 -0
  26. package/supabase/migrations/20260206000015_add_negative_conditions_to_templates.sql +73 -0
  27. package/supabase/migrations/20260206000016_update_init_function_negative_conditions.sql +104 -0
  28. package/supabase/migrations/20260206000017_backfill_negative_conditions_existing_users.sql +109 -0
  29. package/vite.config.ts +1 -1
  30. package/dist/assets/index-BLg38ak1.js +0 -217
  31. package/dist/assets/index-COvYx29q.css +0 -1
@@ -88,14 +88,23 @@ export const schemas = {
88
88
 
89
89
  // Rule schemas - supports both single action (legacy) and actions array
90
90
  // Now includes description and intent for context-aware AI matching
91
+ // Actions support: 'delete', 'archive', 'draft', 'star', 'read', or 'label:*' (e.g., 'label:Financial')
91
92
  createRule: z.object({
92
93
  name: z.string().min(1).max(100),
93
94
  description: z.string().max(500).optional(),
94
95
  intent: z.string().max(200).optional(),
95
96
  priority: z.number().int().min(0).max(100).optional(),
96
97
  condition: z.record(z.unknown()),
97
- action: z.enum(['delete', 'archive', 'draft', 'star', 'read']).optional(),
98
- actions: z.array(z.enum(['delete', 'archive', 'draft', 'star', 'read'])).optional(),
98
+ negative_condition: z.record(z.unknown()).optional(),
99
+ min_confidence: z.number().min(0).max(1).optional().default(0.7),
100
+ action: z.union([
101
+ z.enum(['delete', 'archive', 'draft', 'star', 'read']),
102
+ z.string().regex(/^label:.+/, 'Label actions must start with "label:"')
103
+ ]).optional(),
104
+ actions: z.array(z.union([
105
+ z.enum(['delete', 'archive', 'draft', 'star', 'read']),
106
+ z.string().regex(/^label:.+/, 'Label actions must start with "label:"')
107
+ ])).optional(),
99
108
  instructions: z.string().optional(),
100
109
  is_enabled: z.boolean().default(true),
101
110
  }).refine(data => data.action || (data.actions && data.actions.length > 0), {
@@ -108,8 +117,16 @@ export const schemas = {
108
117
  intent: z.string().max(200).optional(),
109
118
  priority: z.number().int().min(0).max(100).optional(),
110
119
  condition: z.record(z.unknown()).optional(),
111
- action: z.enum(['delete', 'archive', 'draft', 'star', 'read']).optional(),
112
- actions: z.array(z.enum(['delete', 'archive', 'draft', 'star', 'read'])).optional(),
120
+ negative_condition: z.record(z.unknown()).optional(),
121
+ min_confidence: z.number().min(0).max(1).optional(),
122
+ action: z.union([
123
+ z.enum(['delete', 'archive', 'draft', 'star', 'read']),
124
+ z.string().regex(/^label:.+/, 'Label actions must start with "label:"')
125
+ ]).optional(),
126
+ actions: z.array(z.union([
127
+ z.enum(['delete', 'archive', 'draft', 'star', 'read']),
128
+ z.string().regex(/^label:.+/, 'Label actions must start with "label:"')
129
+ ])).optional(),
113
130
  instructions: z.string().optional(),
114
131
  is_enabled: z.boolean().optional(),
115
132
  }),
@@ -12,6 +12,7 @@ import ttsRoutes from './tts.js';
12
12
  import agentRoutes from './agent.js';
13
13
  import draftsRoutes from './drafts.js';
14
14
  import attachmentsRoutes from './attachments.js';
15
+ import learningRoutes from './learning.js';
15
16
 
16
17
 
17
18
  const router = Router();
@@ -30,6 +31,7 @@ router.use('/tts', ttsRoutes);
30
31
  router.use('/agent', agentRoutes);
31
32
  router.use('/drafts', draftsRoutes);
32
33
  router.use('/drafts', attachmentsRoutes);
34
+ router.use('/learning', learningRoutes);
33
35
 
34
36
 
35
37
  export default router;
@@ -0,0 +1,99 @@
1
+ import { Router, Request, Response } from 'express';
2
+ import { authMiddleware } from '../middleware/auth.js';
3
+ import { asyncHandler } from '../middleware/errorHandler.js';
4
+ import { createLogger } from '../utils/logger.js';
5
+
6
+ const router = Router();
7
+ const logger = createLogger('LearningRoutes');
8
+
9
+ /**
10
+ * POST /api/learning/feedback
11
+ * Submit user feedback and immediately update metrics
12
+ */
13
+ router.post('/feedback', authMiddleware, asyncHandler(async (req: Request, res: Response) => {
14
+ const supabase = req.supabase;
15
+ const user = req.user;
16
+
17
+ if (!supabase) {
18
+ return res.status(503).json({ error: 'Supabase client not configured' });
19
+ }
20
+
21
+ if (!user) {
22
+ return res.status(401).json({ error: 'Not authenticated' });
23
+ }
24
+
25
+ const { email_id, feedback_type, original_state, corrected_state, reasoning } = req.body;
26
+
27
+ if (!email_id || !feedback_type) {
28
+ return res.status(400).json({ error: 'Missing required fields' });
29
+ }
30
+
31
+ // Get account_id from email
32
+ const { data: email, error: emailError } = await supabase
33
+ .from('emails')
34
+ .select('account_id')
35
+ .eq('id', email_id)
36
+ .single();
37
+
38
+ if (emailError) {
39
+ logger.warn('Failed to get email account_id', { email_id, error: emailError });
40
+ }
41
+
42
+ // 1. Insert feedback record
43
+ const { error: feedbackError } = await supabase
44
+ .from('user_feedback')
45
+ .insert({
46
+ user_id: user.id,
47
+ email_id,
48
+ account_id: email?.account_id || null, // Populate account_id
49
+ feedback_type,
50
+ original_data: original_state,
51
+ corrected_data: corrected_state,
52
+ reasoning: reasoning || null
53
+ });
54
+
55
+ if (feedbackError) {
56
+ logger.error('Failed to insert feedback', feedbackError);
57
+ return res.status(500).json({ error: 'Failed to submit feedback' });
58
+ }
59
+
60
+ // 2. Immediately update metrics if positive feedback
61
+ if (feedback_type === 'analysis' && corrected_state?.is_correct === true) {
62
+ const { data: metrics, error: fetchError } = await supabase
63
+ .from('learning_metrics')
64
+ .select('*')
65
+ .eq('user_id', user.id)
66
+ .maybeSingle();
67
+
68
+ if (fetchError && fetchError.code !== 'PGRST116') {
69
+ logger.error('Failed to fetch metrics', fetchError);
70
+ } else {
71
+ if (metrics) {
72
+ // Update existing
73
+ await supabase
74
+ .from('learning_metrics')
75
+ .update({
76
+ total_classifications: (metrics.total_classifications || 0) + 1,
77
+ correct_classifications: (metrics.correct_classifications || 0) + 1,
78
+ updated_at: new Date().toISOString()
79
+ })
80
+ .eq('user_id', user.id);
81
+ } else {
82
+ // Create new
83
+ await supabase
84
+ .from('learning_metrics')
85
+ .insert({
86
+ user_id: user.id,
87
+ total_classifications: 1,
88
+ correct_classifications: 1
89
+ });
90
+ }
91
+
92
+ logger.info('Updated metrics for positive feedback', { user_id: user.id });
93
+ }
94
+ }
95
+
96
+ res.json({ success: true });
97
+ }));
98
+
99
+ export default router;
@@ -59,7 +59,7 @@ export const EMAIL_ORG_RULES: DefaultRule[] = [
59
59
  actions: ['label:Sales/Cold Outreach', 'draft'],
60
60
  instructions: 'Politely acknowledge the outreach. If not interested, thank them and decline. If potentially interested, ask for more details.',
61
61
  priority: 20,
62
- is_enabled_by_default: true
62
+ is_enabled_by_default: true // ← RE-ENABLED: recipient_type now implemented!
63
63
  },
64
64
 
65
65
  // Rule 3: CC Organizer
@@ -59,6 +59,11 @@ export interface EnhancedRuleCondition {
59
59
  recipient_count_gt?: number; // Number of recipients threshold
60
60
  is_first_contact?: boolean; // No prior thread with sender
61
61
 
62
+ // Email header metadata (parsed during sync)
63
+ is_automated?: boolean; // Detected from List-Unsubscribe, Precedence:bulk, X-Mailer
64
+ has_unsubscribe?: boolean; // Contains List-Unsubscribe header
65
+ is_reply?: boolean; // Part of a reply thread (In-Reply-To/References headers)
66
+
62
67
  // Content matching
63
68
  contains_keywords?: string[]; // Any of these words (case-insensitive)
64
69
  matches_pattern?: string; // Regex pattern
@@ -69,6 +69,8 @@ export interface RuleContext {
69
69
  intent?: string;
70
70
  actions: string[];
71
71
  draft_instructions?: string;
72
+ condition?: any; // Positive condition
73
+ negative_condition?: any; // Negative condition (exclusion logic)
72
74
  }
73
75
 
74
76
  export interface EmailContext {
@@ -76,10 +78,18 @@ export interface EmailContext {
76
78
  sender: string;
77
79
  date: string;
78
80
  metadata?: {
81
+ // Original metadata fields (deprecated but kept for compatibility)
79
82
  importance?: string;
80
83
  listUnsubscribe?: string;
81
84
  autoSubmitted?: string;
82
85
  mailer?: string;
86
+ // Enhanced header metadata for better LLM analysis
87
+ recipient_type?: 'to' | 'cc' | 'bcc';
88
+ is_automated?: boolean;
89
+ has_unsubscribe?: boolean;
90
+ is_reply?: boolean;
91
+ sender_priority?: 'high' | 'normal' | 'low';
92
+ thread_id?: string;
83
93
  };
84
94
  userPreferences?: {
85
95
  autoTrashSpam?: boolean;
@@ -158,7 +168,37 @@ export class IntelligenceService {
158
168
  const cleanedContent = ContentCleaner.cleanEmailBody(content).substring(0, 2500);
159
169
 
160
170
  const metadataSignals = [];
161
- if (context.metadata?.listUnsubscribe) metadataSignals.push('- Contains Unsubscribe header');
171
+
172
+ // Email age signal (objective time context only)
173
+ if (context.date) {
174
+ const emailDate = new Date(context.date);
175
+ const now = new Date();
176
+ const ageMs = now.getTime() - emailDate.getTime();
177
+ const ageDays = Math.floor(ageMs / (1000 * 60 * 60 * 24));
178
+ const ageHours = Math.floor(ageMs / (1000 * 60 * 60));
179
+
180
+ if (ageDays === 0 && ageHours < 1) {
181
+ metadataSignals.push('- Email age: Less than 1 hour old');
182
+ } else if (ageDays === 0) {
183
+ metadataSignals.push(`- Email age: ${ageHours} hours old`);
184
+ } else if (ageDays === 1) {
185
+ metadataSignals.push('- Email age: 1 day old');
186
+ } else {
187
+ metadataSignals.push(`- Email age: ${ageDays} days old`);
188
+ }
189
+ }
190
+
191
+ // Header-based signals (enhanced)
192
+ if (context.metadata?.recipient_type === 'cc') metadataSignals.push('- Recipient: CC (not directly addressed)');
193
+ if (context.metadata?.recipient_type === 'bcc') metadataSignals.push('- Recipient: BCC (bulk/mass email)');
194
+ if (context.metadata?.is_automated) metadataSignals.push('- Automated/Bulk email detected (List-Unsubscribe or Precedence:bulk)');
195
+ if (context.metadata?.has_unsubscribe) metadataSignals.push('- Contains Unsubscribe header (likely newsletter/marketing)');
196
+ if (context.metadata?.is_reply) metadataSignals.push('- Part of reply thread (ongoing conversation)');
197
+ if (context.metadata?.sender_priority === 'high') metadataSignals.push('- Sender Priority: HIGH (marked as urgent)');
198
+ if (context.metadata?.sender_priority === 'low') metadataSignals.push('- Sender Priority: LOW');
199
+ if (context.metadata?.mailer) metadataSignals.push(`- Sent via: ${context.metadata.mailer}`);
200
+ // Legacy metadata (deprecated but kept for compatibility)
201
+ if (context.metadata?.listUnsubscribe && !context.metadata?.has_unsubscribe) metadataSignals.push('- Contains Unsubscribe header');
162
202
  if (context.metadata?.autoSubmitted && context.metadata.autoSubmitted !== 'no') metadataSignals.push(`- Auto-Submitted: ${context.metadata.autoSubmitted}`);
163
203
  if (context.metadata?.importance) metadataSignals.push(`- Priority: ${context.metadata.importance}`);
164
204
 
@@ -191,11 +231,12 @@ CATEGORY DEFINITIONS:
191
231
 
192
232
  CRITICAL RULES:
193
233
  1. Platform notifications (linkedin.com, github.com) are ALWAYS "notification" or "social", never "personal"
194
- 2. Emails from noreply@, no-reply@ are likely "transactional" or "notification"
234
+ 2. Automated sender addresses (noreply@, no-reply@, donotreply@, alerts@, notifications@, updates@, newsletter@, etc.) are ALWAYS "transactional", "notification", or "newsletter" - NEVER "personal"
195
235
  3. Weekly/Monthly digests are "newsletter"
196
236
  4. If "List-Unsubscribe" header is present, it is likely "newsletter" or "promotional"
197
237
  5. Follow "LEARNED PATTERN" signals strictly if present
198
238
  6. VIP Senders must be "High" priority unless irrelevant (e.g. OOO auto-reply)
239
+ 7. Google Alerts (googlealerts-noreply@google.com) are ALWAYS "news" category, NEVER "personal" or "promotional"
199
240
 
200
241
  FEW-SHOT EXAMPLES:
201
242
 
@@ -520,11 +561,86 @@ REQUIRED JSON STRUCTURE:
520
561
  });
521
562
  const cleanedContent = ContentCleaner.cleanEmailBody(content).substring(0, 2500);
522
563
 
564
+ // Build metadata signals (same as analyzeEmail)
565
+ const metadataSignals = [];
566
+
567
+ // Email age signal (objective time context only)
568
+ if (context.date) {
569
+ const emailDate = new Date(context.date);
570
+ const now = new Date();
571
+ const ageMs = now.getTime() - emailDate.getTime();
572
+ const ageDays = Math.floor(ageMs / (1000 * 60 * 60 * 24));
573
+ const ageHours = Math.floor(ageMs / (1000 * 60 * 60));
574
+
575
+ if (ageDays === 0 && ageHours < 1) {
576
+ metadataSignals.push('- Email age: Less than 1 hour old');
577
+ } else if (ageDays === 0) {
578
+ metadataSignals.push(`- Email age: ${ageHours} hours old`);
579
+ } else if (ageDays === 1) {
580
+ metadataSignals.push('- Email age: 1 day old');
581
+ } else {
582
+ metadataSignals.push(`- Email age: ${ageDays} days old`);
583
+ }
584
+ }
585
+
586
+ // Header-based signals (enhanced)
587
+ if (context.metadata?.recipient_type === 'cc') metadataSignals.push('- Recipient: CC (not directly addressed)');
588
+ if (context.metadata?.recipient_type === 'bcc') metadataSignals.push('- Recipient: BCC (bulk/mass email)');
589
+ if (context.metadata?.is_automated) metadataSignals.push('- Automated/Bulk email detected (likely newsletter/marketing)');
590
+ if (context.metadata?.has_unsubscribe) metadataSignals.push('- Contains Unsubscribe header (newsletter indicator)');
591
+ if (context.metadata?.is_reply) metadataSignals.push('- Part of reply thread (ongoing conversation)');
592
+ if (context.metadata?.sender_priority === 'high') metadataSignals.push('- Sender Priority: HIGH (marked as urgent)');
593
+ if (context.metadata?.sender_priority === 'low') metadataSignals.push('- Sender Priority: LOW');
594
+ if (context.metadata?.mailer) metadataSignals.push(`- Sent via: ${context.metadata.mailer}`);
595
+ // Legacy metadata (deprecated but kept for compatibility)
596
+ if (context.metadata?.listUnsubscribe && !context.metadata?.has_unsubscribe) metadataSignals.push('- Contains Unsubscribe header');
597
+ if (context.metadata?.autoSubmitted && context.metadata.autoSubmitted !== 'no') metadataSignals.push(`- Auto-Submitted: ${context.metadata.autoSubmitted}`);
598
+ if (context.metadata?.importance) metadataSignals.push(`- Priority: ${context.metadata.importance}`);
599
+
600
+ // Adaptive Learning Signals
601
+ if (context.userPreferences?.vipSenders?.includes(context.sender)) {
602
+ metadataSignals.push('- SENDER IS VIP (High Priority Required)');
603
+ }
604
+
605
+ const senderDomain = context.sender.split('@')[1];
606
+ if (senderDomain && context.userPreferences?.categoryPatterns?.[senderDomain]) {
607
+ const learnedCategory = context.userPreferences.categoryPatterns[senderDomain];
608
+ metadataSignals.push(`- LEARNED PATTERN: Sender domain '${senderDomain}' is strictly category '${learnedCategory}'`);
609
+ }
610
+
611
+ // Format rules with positive and negative conditions
612
+ const formatCondition = (cond: any): string => {
613
+ if (!cond) return '';
614
+ if (cond.and) return `(${cond.and.map(formatCondition).join(' AND ')})`;
615
+ if (cond.or) return `(${cond.or.map(formatCondition).join(' OR ')})`;
616
+ if (cond.not) return `NOT ${formatCondition(cond.not)}`;
617
+
618
+ const parts = [];
619
+ for (const [key, value] of Object.entries(cond)) {
620
+ if (key === 'category') parts.push(`category=${value}`);
621
+ else if (key === 'is_automated') parts.push(`is_automated=${value}`);
622
+ else if (key === 'has_unsubscribe') parts.push(`has_unsubscribe=${value}`);
623
+ else if (key === 'recipient_type') parts.push(`recipient_type=${value}`);
624
+ else if (key === 'sender_domain') parts.push(`sender_domain=${value}`);
625
+ else parts.push(`${key}=${value}`);
626
+ }
627
+ return parts.join(', ');
628
+ };
629
+
523
630
  let rulesContext: string;
524
631
  if (typeof compiledRulesContext === 'string') {
525
632
  rulesContext = compiledRulesContext;
526
633
  } else {
527
- rulesContext = compiledRulesContext.map(r => `- ${r.name}: ${r.intent}`).join('\n');
634
+ rulesContext = compiledRulesContext.map(r => {
635
+ let ruleText = `- ${r.name}: ${r.intent || r.description || 'No description'}`;
636
+ if (r.condition) {
637
+ ruleText += `\n Match when: ${formatCondition(r.condition)}`;
638
+ }
639
+ if (r.negative_condition) {
640
+ ruleText += `\n EXCLUDE when: ${formatCondition(r.negative_condition)}`;
641
+ }
642
+ return ruleText;
643
+ }).join('\n');
528
644
  }
529
645
 
530
646
  const systemPrompt = `You are an AI Automation Agent. Analyze the email and identify ALL rules that apply.
@@ -543,6 +659,11 @@ CATEGORY DEFINITIONS:
543
659
  - notification: Platform alerts/notifications (Github, Linear, etc) - distinct from social
544
660
  - other: Anything that doesn't fit above categories
545
661
 
662
+ Email Context:
663
+ - Subject: ${context.subject}
664
+ - From: ${context.sender}
665
+ ${metadataSignals.join('\n')}
666
+
546
667
  Rules Context:
547
668
  ${rulesContext}
548
669
 
@@ -571,7 +692,8 @@ CRITICAL INSTRUCTIONS:
571
692
  - Actions will be merged by the system - you don't need to resolve conflicts
572
693
  - Use "draft" action only if a rule explicitly requests it
573
694
  - Platform notifications (linkedin, github) are ALWAYS "notification" or "social"
574
- - Emails from noreply@ are likely "transactional" or "notification"`;
695
+ - Automated sender addresses (noreply@, no-reply@, donotreply@, alerts@, notifications@, updates@, newsletter@, etc.) are ALWAYS "transactional", "notification", or "newsletter" - NEVER "personal"
696
+ - Google Alerts (googlealerts-noreply@google.com) are ALWAYS "news" category`;
575
697
 
576
698
  if (eventLogger) {
577
699
  await eventLogger.info('Thinking', `Context-aware analysis: ${context.subject}`, {
@@ -226,8 +226,13 @@ export class LearningService {
226
226
 
227
227
  private async updateMetrics(userId: string, items: UserFeedback[]): Promise<void> {
228
228
  try {
229
- // Simple counters for now
230
- const corrections = items.filter(i => i.feedback_type === 'analysis').length;
229
+ // Separate positive feedback (correct) from corrections (incorrect)
230
+ const positiveFeedback = items.filter(i =>
231
+ i.feedback_type === 'analysis' && i.corrected_data?.is_correct === true
232
+ );
233
+ const corrections = items.filter(i =>
234
+ i.feedback_type === 'analysis' && i.corrected_data?.is_correct !== true
235
+ );
231
236
  const draftEdits = items.filter(i => i.feedback_type === 'draft_edit').length;
232
237
 
233
238
  // Fetch existing or create
@@ -247,8 +252,7 @@ export class LearningService {
247
252
  .from('learning_metrics')
248
253
  .update({
249
254
  total_classifications: (existing.total_classifications || 0) + items.length,
250
- // If it's a correction, it wasn't correct. If it's pure positive feedback... (not impl yet)
251
- // For now, this is just activity tracking
255
+ correct_classifications: (existing.correct_classifications || 0) + positiveFeedback.length,
252
256
  drafts_edited: (existing.drafts_edited || 0) + draftEdits,
253
257
  updated_at: new Date().toISOString()
254
258
  })
@@ -261,11 +265,19 @@ export class LearningService {
261
265
  .insert({
262
266
  user_id: userId,
263
267
  total_classifications: items.length,
268
+ correct_classifications: positiveFeedback.length,
264
269
  drafts_edited: draftEdits
265
270
  });
266
271
 
267
272
  if (insertError) throw insertError;
268
273
  }
274
+
275
+ logger.info('Updated learning metrics', {
276
+ userId,
277
+ positive: positiveFeedback.length,
278
+ corrections: corrections.length,
279
+ total: items.length
280
+ });
269
281
  } catch (error) {
270
282
  logger.error('Failed to update learning metrics', error as Error);
271
283
  }