@realtimex/email-automator 2.11.2 → 2.12.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 (47) hide show
  1. package/api/src/routes/index.ts +2 -0
  2. package/api/src/routes/rulePacks.ts +204 -0
  3. package/api/src/services/RulePackService.ts +354 -0
  4. package/api/src/services/SDKService.ts +18 -7
  5. package/api/src/services/gmail.ts +68 -0
  6. package/api/src/services/intelligence.ts +160 -22
  7. package/api/src/services/microsoft.ts +99 -0
  8. package/api/src/services/processor.ts +146 -12
  9. package/api/src/services/rulePacks/developer.ts +179 -0
  10. package/api/src/services/rulePacks/executive.ts +143 -0
  11. package/api/src/services/rulePacks/index.ts +63 -0
  12. package/api/src/services/rulePacks/operations.ts +183 -0
  13. package/api/src/services/rulePacks/sales.ts +160 -0
  14. package/api/src/services/rulePacks/types.ts +116 -0
  15. package/api/src/services/rulePacks/universal.ts +83 -0
  16. package/api/src/services/supabase.ts +40 -0
  17. package/dist/api/src/routes/index.js +2 -0
  18. package/dist/api/src/routes/rulePacks.js +179 -0
  19. package/dist/api/src/services/RulePackService.js +296 -0
  20. package/dist/api/src/services/SDKService.js +18 -7
  21. package/dist/api/src/services/gmail.js +56 -0
  22. package/dist/api/src/services/intelligence.js +153 -21
  23. package/dist/api/src/services/microsoft.js +79 -0
  24. package/dist/api/src/services/processor.js +133 -12
  25. package/dist/api/src/services/rulePacks/developer.js +176 -0
  26. package/dist/api/src/services/rulePacks/executive.js +140 -0
  27. package/dist/api/src/services/rulePacks/index.js +58 -0
  28. package/dist/api/src/services/rulePacks/operations.js +180 -0
  29. package/dist/api/src/services/rulePacks/sales.js +157 -0
  30. package/dist/api/src/services/rulePacks/types.js +7 -0
  31. package/dist/api/src/services/rulePacks/universal.js +80 -0
  32. package/dist/assets/index-B5rXh3y8.css +1 -0
  33. package/dist/assets/index-B62ViZum.js +105 -0
  34. package/dist/index.html +2 -2
  35. package/package.json +1 -1
  36. package/supabase/migrations/20260131000000_add_rule_packs.sql +176 -0
  37. package/supabase/migrations/20260131000001_set_zero_config_defaults.sql +51 -0
  38. package/supabase/migrations/20260131095000_backfill_user_settings.sql +36 -0
  39. package/supabase/migrations/20260131100000_rule_templates_table.sql +154 -0
  40. package/supabase/migrations/20260131110000_auto_init_user_data.sql +90 -0
  41. package/supabase/migrations/20260131120000_backfill_universal_pack.sql +84 -0
  42. package/supabase/migrations/20260131130000_simplify_rules_with_categories.sql +87 -0
  43. package/supabase/migrations/20260131140000_fix_action_constraint.sql +11 -0
  44. package/supabase/migrations/20260131150000_fix_trigger_error_handling.sql +71 -0
  45. package/supabase/migrations/20260131160000_enable_intelligent_rename_by_default.sql +14 -0
  46. package/dist/assets/index-BuWrl4UD.js +0 -105
  47. package/dist/assets/index-CtDzSy0n.css +0 -1
@@ -441,6 +441,74 @@ export class GmailService {
441
441
  logger.debug('Message starred', { messageId });
442
442
  }
443
443
 
444
+ /**
445
+ * Get or create a label by name (supports nested labels like "Finance/Receipts")
446
+ * Returns the label ID
447
+ */
448
+ async getOrCreateLabel(account: EmailAccount, labelPath: string): Promise<string> {
449
+ const gmail = await this.getAuthenticatedClient(account);
450
+
451
+ // List existing labels
452
+ const { data: labelsData } = await gmail.users.labels.list({ userId: 'me' });
453
+ const existingLabels = labelsData.labels || [];
454
+
455
+ // Check if label already exists
456
+ const existingLabel = existingLabels.find(l => l.name === labelPath);
457
+ if (existingLabel?.id) {
458
+ logger.debug('Label already exists', { labelPath, labelId: existingLabel.id });
459
+ return existingLabel.id;
460
+ }
461
+
462
+ // Create nested labels if needed (e.g., "Finance/Receipts" creates "Finance" then "Finance/Receipts")
463
+ const parts = labelPath.split('/');
464
+ let currentPath = '';
465
+ let parentId: string | undefined;
466
+
467
+ for (const part of parts) {
468
+ currentPath = currentPath ? `${currentPath}/${part}` : part;
469
+
470
+ // Check if this level exists
471
+ const existing = existingLabels.find(l => l.name === currentPath);
472
+ if (existing?.id) {
473
+ parentId = existing.id;
474
+ continue;
475
+ }
476
+
477
+ // Create this level
478
+ const { data: newLabel } = await gmail.users.labels.create({
479
+ userId: 'me',
480
+ requestBody: {
481
+ name: currentPath,
482
+ labelListVisibility: 'labelShow',
483
+ messageListVisibility: 'show'
484
+ }
485
+ });
486
+
487
+ if (!newLabel.id) {
488
+ throw new Error(`Failed to create label: ${currentPath}`);
489
+ }
490
+
491
+ logger.info('Created label', { labelPath: currentPath, labelId: newLabel.id });
492
+ parentId = newLabel.id;
493
+ }
494
+
495
+ if (!parentId) {
496
+ throw new Error(`Failed to get or create label: ${labelPath}`);
497
+ }
498
+
499
+ return parentId;
500
+ }
501
+
502
+ /**
503
+ * Apply a label to a message by label name (creates label if needed)
504
+ * Supports nested labels like "Finance/Receipts"
505
+ */
506
+ async applyLabelByName(account: EmailAccount, messageId: string, labelName: string): Promise<void> {
507
+ const labelId = await this.getOrCreateLabel(account, labelName);
508
+ await this.addLabel(account, messageId, [labelId]);
509
+ logger.debug('Applied label to message', { messageId, labelName, labelId });
510
+ }
511
+
444
512
  private async fetchAttachment(supabase: SupabaseClient, path: string): Promise<Uint8Array> {
445
513
  const { data, error } = await supabase.storage
446
514
  .from('rule-attachments')
@@ -91,7 +91,7 @@ export class IntelligenceService {
91
91
  return this.isConfigured && !!SDKService.getSDK();
92
92
  }
93
93
 
94
- async analyzeEmail(content: string, context: EmailContext, eventLogger?: EventLogger, emailId?: string): Promise<(EmailAnalysis & { _metadata?: any }) | null> {
94
+ async analyzeEmail(content: string, context: EmailContext, eventLogger?: EventLogger, emailId?: string, llmSettings?: { llm_provider?: string; llm_model?: string }): Promise<(EmailAnalysis & { _metadata?: any }) | null> {
95
95
  const sdk = SDKService.getSDK();
96
96
  if (!sdk) {
97
97
  logger.warn('Intelligence service not ready, skipping analysis');
@@ -101,7 +101,10 @@ export class IntelligenceService {
101
101
  return null;
102
102
  }
103
103
 
104
- const { provider, model, isDefaultFallback } = await SDKService.resolveChatProvider({});
104
+ const { provider, model, isDefaultFallback } = await SDKService.resolveChatProvider({
105
+ llm_provider: llmSettings?.llm_provider,
106
+ llm_model: llmSettings?.llm_model
107
+ });
105
108
 
106
109
  const cleanedContent = ContentCleaner.cleanEmailBody(content).substring(0, 2500);
107
110
 
@@ -134,10 +137,10 @@ REQUIRED JSON STRUCTURE:
134
137
 
135
138
  if (eventLogger) {
136
139
  await eventLogger.info('Thinking', `Analyzing email: ${context.subject}`, {
137
- model,
138
- provider,
140
+ provider: `${provider}/${model}`,
139
141
  is_fallback: isDefaultFallback,
140
- content_preview: cleanedContent
142
+ signals: metadataSignals,
143
+ content_preview: cleanedContent.substring(0, 100) + '...'
141
144
  }, emailId);
142
145
  }
143
146
 
@@ -147,8 +150,27 @@ REQUIRED JSON STRUCTURE:
147
150
  { role: 'user', content: cleanedContent || '[Empty body]' }
148
151
  ], { provider, model });
149
152
 
153
+ // Check if SDK call failed
154
+ if (!response.success || response.error) {
155
+ const errorMsg = response.error || 'Unknown SDK error';
156
+ logger.error('SDK chat failed for email analysis', {
157
+ provider,
158
+ model,
159
+ error: errorMsg,
160
+ code: response.code
161
+ });
162
+ if (eventLogger) await eventLogger.error('SDK Error', `${errorMsg} (${provider}/${model})`, emailId);
163
+ return null;
164
+ }
165
+
150
166
  const rawResponse = response.response?.content || '';
151
- const validated = this.parseRobustJSON<EmailAnalysis>(rawResponse, EmailAnalysisSchema);
167
+ if (!rawResponse) {
168
+ logger.warn('SDK returned empty response for analysis', { provider, model });
169
+ if (eventLogger) await eventLogger.error('Empty Response', `LLM (${provider}/${model}) returned no content`, emailId);
170
+ return null;
171
+ }
172
+
173
+ const validated = this.parseRobustJSON<EmailAnalysis>(rawResponse, EmailAnalysisSchema, eventLogger, emailId);
152
174
 
153
175
  const result = validated ? {
154
176
  ...validated,
@@ -165,6 +187,11 @@ REQUIRED JSON STRUCTURE:
165
187
  ...result,
166
188
  _raw_response: rawResponse
167
189
  });
190
+ } else if (eventLogger && !result) {
191
+ await eventLogger.error('Malformed Response', {
192
+ message: 'AI returned data that did not match the required schema',
193
+ raw_response: rawResponse.substring(0, 500)
194
+ }, emailId);
168
195
  }
169
196
 
170
197
  return result;
@@ -177,12 +204,16 @@ REQUIRED JSON STRUCTURE:
177
204
 
178
205
  async generateDraftReply(
179
206
  originalEmail: { subject: string; sender: string; body: string },
180
- instructions?: string
207
+ instructions?: string,
208
+ llmSettings?: { llm_provider?: string; llm_model?: string }
181
209
  ): Promise<string | null> {
182
210
  const sdk = SDKService.getSDK();
183
211
  if (!sdk) return null;
184
212
 
185
- const { provider, model } = await SDKService.resolveChatProvider({});
213
+ const { provider, model } = await SDKService.resolveChatProvider({
214
+ llm_provider: llmSettings?.llm_provider,
215
+ llm_model: llmSettings?.llm_model
216
+ });
186
217
 
187
218
  try {
188
219
  const response = await sdk.llm.chat([
@@ -196,6 +227,17 @@ REQUIRED JSON STRUCTURE:
196
227
  },
197
228
  ], { provider, model });
198
229
 
230
+ // Check if SDK call failed
231
+ if (!response.success || response.error) {
232
+ logger.error('SDK chat failed for draft generation', {
233
+ provider,
234
+ model,
235
+ error: response.error,
236
+ code: response.code
237
+ });
238
+ return null;
239
+ }
240
+
199
241
  return response.response?.content || null;
200
242
  } catch (error) {
201
243
  logger.error('Draft generation failed', error);
@@ -208,12 +250,16 @@ REQUIRED JSON STRUCTURE:
208
250
  context: EmailContext,
209
251
  compiledRulesContext: string | RuleContext[],
210
252
  eventLogger?: EventLogger,
211
- emailId?: string
253
+ emailId?: string,
254
+ llmSettings?: { llm_provider?: string; llm_model?: string }
212
255
  ): Promise<(ContextAwareAnalysis & { _metadata?: any }) | null> {
213
256
  const sdk = SDKService.getSDK();
214
257
  if (!sdk) return null;
215
258
 
216
- const { provider, model, isDefaultFallback } = await SDKService.resolveChatProvider({});
259
+ const { provider, model, isDefaultFallback } = await SDKService.resolveChatProvider({
260
+ llm_provider: llmSettings?.llm_provider,
261
+ llm_model: llmSettings?.llm_model
262
+ });
217
263
  const cleanedContent = ContentCleaner.cleanEmailBody(content).substring(0, 2500);
218
264
 
219
265
  let rulesContext: string;
@@ -223,24 +269,71 @@ REQUIRED JSON STRUCTURE:
223
269
  rulesContext = compiledRulesContext.map(r => `- ${r.name}: ${r.intent}`).join('\n');
224
270
  }
225
271
 
226
- const systemPrompt = `You are an AI Automation Agent. Match email against these rules:\n${rulesContext}\n\nReturn JSON with matched_rule, actions_to_execute, and draft_content.`;
272
+ const systemPrompt = `You are an AI Automation Agent. Analyze the email and match it against the user's rules.
273
+
274
+ Rules Context:
275
+ ${rulesContext}
276
+
277
+ REQUIRED JSON STRUCTURE:
278
+ {
279
+ "summary": "A brief summary of the email content",
280
+ "category": "spam|newsletter|promotional|transactional|social|support|client|internal|personal|other",
281
+ "priority": "High|Medium|Low",
282
+ "matched_rule": {
283
+ "rule_id": "string or null",
284
+ "rule_name": "string or null",
285
+ "confidence": 0.0 to 1.0,
286
+ "reasoning": "Brief explanation"
287
+ },
288
+ "actions_to_execute": ["none"|"delete"|"archive"|"draft"|"read"|"star"],
289
+ "draft_content": "Suggested reply if drafting, otherwise null"
290
+ }
291
+
292
+ IMPORTANT:
293
+ - Use "draft" action only if a rule explicitly requests it or if it's very clear a reply is needed.
294
+ - Categorize accurately.
295
+ - Confidence 0.7+ is required for automatic execution.`;
227
296
 
228
297
  if (eventLogger) {
229
298
  await eventLogger.info('Thinking', `Context-aware analysis: ${context.subject}`, {
230
- model,
231
- provider,
232
- is_fallback: isDefaultFallback
299
+ provider: `${provider}/${model}`,
300
+ is_fallback: isDefaultFallback,
301
+ rules_count: Array.isArray(compiledRulesContext) ? compiledRulesContext.length : 'compiled'
233
302
  }, emailId);
234
303
  }
235
304
 
236
305
  try {
306
+ logger.debug('Calling SDK chat for rule analysis', { provider, model, promptLength: systemPrompt.length });
237
307
  const response = await sdk.llm.chat([
238
308
  { role: 'system', content: systemPrompt },
239
309
  { role: 'user', content: cleanedContent || '[Empty body]' }
240
310
  ], { provider, model });
241
311
 
312
+ // Check if SDK call failed
313
+ if (!response.success || response.error) {
314
+ const errorMsg = response.error || 'Unknown SDK error';
315
+ logger.error('SDK chat failed for rule analysis', {
316
+ provider,
317
+ model,
318
+ error: errorMsg,
319
+ code: response.code
320
+ });
321
+ if (eventLogger) await eventLogger.error('SDK Error', `${errorMsg} (${provider}/${model})`, emailId);
322
+ return null;
323
+ }
324
+
242
325
  const rawResponse = response.response?.content || '';
243
- const validated = this.parseRobustJSON<ContextAwareAnalysis>(rawResponse, ContextAwareAnalysisSchema);
326
+ if (!rawResponse) {
327
+ logger.warn('SDK returned empty response for rule analysis', {
328
+ provider,
329
+ model,
330
+ success: response.success
331
+ });
332
+ if (eventLogger) await eventLogger.error('Empty Response', `LLM (${provider}/${model}) returned no content`, emailId);
333
+ return null;
334
+ }
335
+
336
+ const validated = this.parseRobustJSON<ContextAwareAnalysis>(rawResponse, ContextAwareAnalysisSchema, eventLogger, emailId);
244
337
 
245
338
  const result = validated ? {
246
339
  ...validated,
@@ -257,12 +350,23 @@ REQUIRED JSON STRUCTURE:
257
350
  ...result,
258
351
  _raw_response: rawResponse
259
352
  });
353
+ } else if (eventLogger && !result) {
354
+ await eventLogger.error('Malformed Response', {
355
+ message: 'AI returned rule analysis that did not match the required schema',
356
+ raw_response: rawResponse.substring(0, 500)
357
+ }, emailId);
260
358
  }
261
359
 
262
360
  return result;
263
361
  } catch (error: any) {
264
- logger.error('Rule analysis failed', error);
265
- if (eventLogger) await eventLogger.error('Error', error.message, emailId);
362
+ logger.error('Rule analysis failed', {
363
+ error: error.message,
364
+ stack: error.stack,
365
+ provider,
366
+ model,
367
+ errorType: error.constructor.name
368
+ });
369
+ if (eventLogger) await eventLogger.error('Error', `${error.message} (${provider}/${model})`, emailId);
266
370
  return null;
267
371
  }
268
372
  }
@@ -283,14 +387,48 @@ REQUIRED JSON STRUCTURE:
283
387
  }
284
388
  }
285
389
 
286
- private parseRobustJSON<T>(input: string, schema: z.ZodSchema<T>): T | null {
390
+ private parseRobustJSON<T>(input: string, schema: z.ZodSchema<T>, eventLogger?: EventLogger, emailId?: string): T | null {
287
391
  try {
288
- const jsonMatch = input.match(/\{[\s\S]*\}/);
289
- const jsonStr = jsonMatch ? jsonMatch[0] : input;
290
- const cleaned = jsonStr.replace(/<\|[\s\S]*?\|>/g, '').replace(/```json/g, '').replace(/```/g, '').trim();
392
+ // 1. Remove common LLM artifacts and markdown blocks
393
+ let cleaned = input.trim();
394
+
395
+ // Handle markdown blocks
396
+ if (cleaned.includes('```json')) {
397
+ cleaned = cleaned.split('```json')[1].split('```')[0].trim();
398
+ } else if (cleaned.includes('```')) {
399
+ cleaned = cleaned.split('```')[1].split('```')[0].trim();
400
+ }
401
+
402
+ // 2. Extract the first { ... } block if visible
403
+ const jsonMatch = cleaned.match(/\{[\s\S]*\}/);
404
+ if (jsonMatch) {
405
+ cleaned = jsonMatch[0];
406
+ }
407
+
408
+ // 3. Strip aggressive local LLM tokens
409
+ cleaned = cleaned.replace(/<\|[\s\S]*?\|>/g, '').trim();
410
+
411
+ // 4. Parse and Normalize
291
412
  const parsed = JSON.parse(cleaned);
413
+
414
+ // Normalize actions_to_execute: convert string to array if needed
415
+ if (parsed && typeof parsed === 'object' && 'actions_to_execute' in parsed) {
416
+ if (typeof parsed.actions_to_execute === 'string') {
417
+ parsed.actions_to_execute = [parsed.actions_to_execute];
418
+ logger.debug('Normalized actions_to_execute from string to array', { original: parsed.actions_to_execute[0] });
419
+ }
420
+ }
421
+
422
+ // 5. Validate with Zod
292
423
  return schema.parse(parsed);
293
- } catch (e) {
424
+ } catch (e: any) {
425
+ logger.error('JSON Robust Parsing failed', { error: e.message, input: input.substring(0, 200) });
426
+ if (eventLogger && emailId) {
427
+ eventLogger.error('JSON Parse Error', {
428
+ error: e.message,
429
+ raw_input_preview: input.substring(0, 500)
430
+ }, emailId).catch(() => { });
431
+ }
294
432
  return null;
295
433
  }
296
434
  }
@@ -403,6 +403,105 @@ export class MicrosoftService {
403
403
  return draft.id;
404
404
  }
405
405
 
406
+ /**
407
+ * Get or create a folder by path (supports nested folders like "Finance/Receipts")
408
+ * Returns the folder ID
409
+ */
410
+ async getOrCreateFolder(account: EmailAccount, folderPath: string): Promise<string> {
411
+ const accessToken = account.access_token || '';
412
+
413
+ // Split path into parts
414
+ const parts = folderPath.split('/');
415
+ let parentFolderId: string | null = null;
416
+
417
+ for (const folderName of parts) {
418
+ // List folders (either root or under parent)
419
+ const listUrl: string = parentFolderId
420
+ ? `https://graph.microsoft.com/v1.0/me/mailFolders/${parentFolderId}/childFolders`
421
+ : 'https://graph.microsoft.com/v1.0/me/mailFolders';
422
+
423
+ const listResponse: Response = await fetch(listUrl, {
424
+ headers: {
425
+ Authorization: `Bearer ${accessToken}`,
426
+ },
427
+ });
428
+
429
+ if (!listResponse.ok) {
430
+ throw new Error(`Failed to list folders: ${listResponse.statusText}`);
431
+ }
432
+
433
+ const foldersData: any = await listResponse.json();
434
+ const folders: any[] = foldersData.value || [];
435
+
436
+ // Check if folder exists at this level
437
+ const existingFolder: any = folders.find((f: any) => f.displayName === folderName);
438
+ if (existingFolder) {
439
+ parentFolderId = existingFolder.id;
440
+ logger.debug('Folder exists', { folderName, folderId: existingFolder.id });
441
+ continue;
442
+ }
443
+
444
+ // Create folder at this level
445
+ const createUrl = parentFolderId
446
+ ? `https://graph.microsoft.com/v1.0/me/mailFolders/${parentFolderId}/childFolders`
447
+ : 'https://graph.microsoft.com/v1.0/me/mailFolders';
448
+
449
+ const createResponse = await fetch(createUrl, {
450
+ method: 'POST',
451
+ headers: {
452
+ Authorization: `Bearer ${accessToken}`,
453
+ 'Content-Type': 'application/json',
454
+ },
455
+ body: JSON.stringify({
456
+ displayName: folderName,
457
+ }),
458
+ });
459
+
460
+ if (!createResponse.ok) {
461
+ throw new Error(`Failed to create folder: ${folderName}`);
462
+ }
463
+
464
+ const newFolder = await createResponse.json();
465
+ parentFolderId = newFolder.id;
466
+ logger.info('Created folder', { folderName, folderId: newFolder.id });
467
+ }
468
+
469
+ if (!parentFolderId) {
470
+ throw new Error(`Failed to get or create folder: ${folderPath}`);
471
+ }
472
+
473
+ return parentFolderId;
474
+ }
475
+
476
+ /**
477
+ * Move a message to a folder by folder path (creates folder if needed)
478
+ * Supports nested folders like "Finance/Receipts"
479
+ */
480
+ async moveToFolderByPath(account: EmailAccount, messageId: string, folderPath: string): Promise<void> {
481
+ const folderId = await this.getOrCreateFolder(account, folderPath);
482
+ const accessToken = account.access_token || '';
483
+
484
+ const response = await fetch(
485
+ `https://graph.microsoft.com/v1.0/me/messages/${messageId}/move`,
486
+ {
487
+ method: 'POST',
488
+ headers: {
489
+ Authorization: `Bearer ${accessToken}`,
490
+ 'Content-Type': 'application/json',
491
+ },
492
+ body: JSON.stringify({
493
+ destinationId: folderId,
494
+ }),
495
+ }
496
+ );
497
+
498
+ if (!response.ok) {
499
+ throw new Error(`Failed to move message to folder: ${folderPath}`);
500
+ }
501
+
502
+ logger.debug('Moved message to folder', { messageId, folderPath, folderId });
503
+ }
504
+
406
505
  async getProfile(account: EmailAccount): Promise<{ emailAddress: string; displayName: string }> {
407
506
  const accessToken = account.access_token || '';
408
507