@realtimex/email-automator 2.20.0 → 2.21.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 (43) hide show
  1. package/api/src/config/index.ts +9 -8
  2. package/api/src/routes/drafts.ts +242 -0
  3. package/api/src/routes/index.ts +2 -0
  4. package/api/src/services/gmail.ts +66 -3
  5. package/api/src/services/intelligence.ts +133 -17
  6. package/api/src/services/learning.ts +273 -0
  7. package/api/src/services/microsoft.ts +64 -1
  8. package/api/src/services/processor.ts +139 -8
  9. package/api/src/services/scheduler.ts +44 -0
  10. package/api/src/services/supabase.ts +73 -3
  11. package/api/src/utils/nameExtraction.ts +136 -0
  12. package/api/src/utils/senderValidation.ts +143 -0
  13. package/dist/api/src/config/index.js +7 -6
  14. package/dist/api/src/routes/drafts.js +187 -0
  15. package/dist/api/src/routes/index.js +2 -0
  16. package/dist/api/src/services/gmail.js +53 -2
  17. package/dist/api/src/services/intelligence.js +114 -17
  18. package/dist/api/src/services/learning.js +231 -0
  19. package/dist/api/src/services/microsoft.js +42 -0
  20. package/dist/api/src/services/processor.js +115 -9
  21. package/dist/api/src/services/scheduler.js +40 -0
  22. package/dist/api/src/services/supabase.js +2 -2
  23. package/dist/api/src/utils/nameExtraction.js +109 -0
  24. package/dist/api/src/utils/senderValidation.js +120 -0
  25. package/dist/assets/{es-jMIxKu3w.js → es-D3VfHd3p.js} +1 -1
  26. package/dist/assets/{fr-C2DJGnGk.js → fr-BCaT2FDG.js} +1 -1
  27. package/dist/assets/index-BNIm2wsr.js +130 -0
  28. package/dist/assets/index-DwCPoGUC.css +1 -0
  29. package/dist/assets/{ja-BAbHgg3S.js → ja-46h-LNuz.js} +1 -1
  30. package/dist/assets/{ko-BqELMO5T.js → ko-D7KXrzVC.js} +1 -1
  31. package/dist/assets/{vi-D_3oX_6P.js → vi-C3rVsi7-.js} +1 -1
  32. package/dist/index.html +2 -2
  33. package/package.json +1 -1
  34. package/scripts/cleanup-invalid-drafts.sql +42 -0
  35. package/supabase/functions/api-v1-drafts/index.ts +34 -13
  36. package/supabase/functions/learning-daily-job/index.ts +122 -0
  37. package/supabase/migrations/20260202000001_add_persona_to_settings.sql +34 -0
  38. package/supabase/migrations/20260202000002_create_user_feedback.sql +50 -0
  39. package/supabase/migrations/20260203000000_phase4_foundations.sql +53 -0
  40. package/dist/assets/index-Czj6FLF_.js +0 -105
  41. package/dist/assets/index-dbXCkqFe.css +0 -1
  42. package/supabase/functions/_shared/gmail-service.ts +0 -4
  43. package/supabase/functions/_shared/microsoft-service.ts +0 -4
@@ -48,10 +48,10 @@ function parseArgs(args: string[]): { port: number | null, noUi: boolean, rename
48
48
  port = p;
49
49
  }
50
50
  }
51
-
51
+
52
52
  const noUi = args.includes('--no-ui');
53
53
  const rename = args.includes('--rename');
54
-
54
+
55
55
  return { port, noUi, rename };
56
56
  }
57
57
 
@@ -120,12 +120,13 @@ export const config = {
120
120
  export function validateConfig(): { valid: boolean; errors: string[] } {
121
121
  const errors: string[] = [];
122
122
 
123
- if (!config.supabase.url) {
124
- errors.push('SUPABASE_URL is required');
125
- }
126
- if (!config.supabase.anonKey) {
127
- errors.push('SUPABASE_ANON_KEY is required');
128
- }
123
+ // For BYOK (Bring Your Own Key) apps, .env is optional
124
+ // if (!config.supabase.url) {
125
+ // errors.push('SUPABASE_URL is required');
126
+ // }
127
+ // if (!config.supabase.anonKey) {
128
+ // errors.push('SUPABASE_ANON_KEY is required');
129
+ // }
129
130
  if (config.isProduction && config.security.jwtSecret === 'dev-secret-change-in-production') {
130
131
  errors.push('JWT_SECRET must be set in production');
131
132
  }
@@ -0,0 +1,242 @@
1
+ import { Router } from 'express';
2
+ import { asyncHandler, NotFoundError } from '../middleware/errorHandler.js';
3
+ import { authMiddleware } from '../middleware/auth.js';
4
+ import { apiRateLimit } from '../middleware/rateLimit.js';
5
+ import { getGmailService } from '../services/gmail.js';
6
+ import { getMicrosoftService } from '../services/microsoft.js';
7
+ import { createLogger } from '../utils/logger.js';
8
+
9
+ const router = Router();
10
+ const logger = createLogger('DraftsRoutes');
11
+
12
+ // List drafts
13
+ router.get('/',
14
+ apiRateLimit,
15
+ authMiddleware,
16
+ asyncHandler(async (req, res) => {
17
+ const {
18
+ limit = '100',
19
+ offset = '0',
20
+ status = 'pending',
21
+ account_id
22
+ } = req.query;
23
+
24
+ let query = req.supabase!
25
+ .from('emails')
26
+ .select(`
27
+ id, subject, sender, recipient, date,
28
+ draft_status, draft_created_at, draft_content, ai_analysis,
29
+ account_id, external_id, draft_id,
30
+ email_accounts!inner(id, user_id, email_address, provider)
31
+ `, { count: 'exact' })
32
+ .eq('email_accounts.user_id', req.user!.id)
33
+ .eq('draft_status', status)
34
+ .order('draft_created_at', { ascending: false })
35
+ .range(
36
+ parseInt(offset as string, 10),
37
+ parseInt(offset as string, 10) + parseInt(limit as string, 10) - 1
38
+ );
39
+
40
+ if (account_id) {
41
+ query = query.eq('account_id', account_id);
42
+ }
43
+
44
+ const { data, error, count } = await query;
45
+
46
+ if (error) throw error;
47
+
48
+ // Debug: Log draft content structure for first few emails
49
+ if (data && data.length > 0) {
50
+ logger.info(`Found ${data.length} drafts with draft_status='${status}'`);
51
+ data.slice(0, 3).forEach((email, idx) => {
52
+ const aiAnalysis = email.ai_analysis as any;
53
+ logger.debug(`Draft ${idx + 1} content check`, {
54
+ emailId: email.id,
55
+ hasDraftContent: !!email.draft_content,
56
+ hasAiAnalysis: !!email.ai_analysis,
57
+ aiAnalysisKeys: email.ai_analysis ? Object.keys(email.ai_analysis) : [],
58
+ hasDraftResponse: !!aiAnalysis?.draft_response,
59
+ hasDraftContentInAi: !!aiAnalysis?.draft_content,
60
+ draftResponseLength: aiAnalysis?.draft_response?.length || 0,
61
+ draftContentLength: aiAnalysis?.draft_content?.length || 0,
62
+ persistedDraftLength: email.draft_content?.length || 0
63
+ });
64
+ });
65
+ }
66
+
67
+ // Filter out drafts without content (these can't be sent)
68
+ const validDrafts = (data || []).filter(email => {
69
+ const aiAnalysis = email.ai_analysis as any;
70
+ const hasContent = email.draft_content || aiAnalysis?.draft_response || aiAnalysis?.draft_content;
71
+
72
+ if (!hasContent) {
73
+ logger.debug('Draft without content filtered out', {
74
+ emailId: email.id,
75
+ subject: email.subject,
76
+ hasAiAnalysis: !!email.ai_analysis,
77
+ aiAnalysisKeys: email.ai_analysis ? Object.keys(email.ai_analysis) : []
78
+ });
79
+ }
80
+
81
+ return hasContent;
82
+ });
83
+
84
+ logger.info(`Returning ${validDrafts.length} valid drafts out of ${data?.length || 0} total`);
85
+
86
+ res.json({
87
+ drafts: validDrafts,
88
+ total: validDrafts.length
89
+ });
90
+ })
91
+ );
92
+
93
+ // Send draft
94
+ router.post('/:emailId/send',
95
+ apiRateLimit,
96
+ authMiddleware,
97
+ asyncHandler(async (req, res) => {
98
+ const { emailId } = req.params;
99
+ const userId = req.user!.id;
100
+
101
+ // Fetch email with account info and SECURITY CHECK in query
102
+ const { data: email, error } = await req.supabase!
103
+ .from('emails')
104
+ .select('*, email_accounts!inner(*)')
105
+ .eq('id', emailId)
106
+ .eq('email_accounts.user_id', userId) // Security: Ensure user owns the account
107
+ .single();
108
+
109
+ if (error || !email) {
110
+ throw new NotFoundError('Email');
111
+ }
112
+
113
+ // if (email.email_accounts.user_id !== userId) {
114
+ // throw new NotFoundError('Email');
115
+ // }
116
+
117
+ const account = email.email_accounts;
118
+ // CRITICAL: Priority Definition
119
+ // 1. draft_content (User edits / Persisted draft) overrides everything
120
+ // 2. ai_analysis.draft_response (EmailAnalysisSchema)
121
+ // 3. ai_analysis.draft_content (ContextAwareAnalysisSchema)
122
+ const aiAnalysis = email.ai_analysis as any;
123
+ const draftContent = email.draft_content ?? aiAnalysis?.draft_response ?? aiAnalysis?.draft_content;
124
+
125
+ logger.debug('Sending draft', {
126
+ emailId,
127
+ hasDraftContent: !!email.draft_content,
128
+ hasAiDraftResponse: !!aiAnalysis?.draft_response,
129
+ hasAiDraftContent: !!aiAnalysis?.draft_content,
130
+ contentLength: draftContent?.length || 0,
131
+ analysisKeys: email.ai_analysis ? Object.keys(email.ai_analysis) : []
132
+ });
133
+
134
+ if (!draftContent) {
135
+ logger.warn('Draft content missing', {
136
+ emailId,
137
+ draft_content: email.draft_content,
138
+ ai_draft_response: aiAnalysis?.draft_response,
139
+ ai_draft_content: aiAnalysis?.draft_content,
140
+ analysisKeys: email.ai_analysis ? Object.keys(email.ai_analysis) : []
141
+ });
142
+ return res.status(400).json({
143
+ error: 'No draft content found for this email',
144
+ details: 'No draft found in draft_content, ai_analysis.draft_response, or ai_analysis.draft_content fields'
145
+ });
146
+ }
147
+
148
+ let sentMessageId: string | null = null;
149
+ const replyToId = email.external_id;
150
+
151
+ try {
152
+ // Priority: Send existing draft if ID exists (cleaner, preserves original draft object)
153
+ if (email.draft_id) {
154
+ if (account.provider === 'gmail') {
155
+ const gmailService = getGmailService();
156
+ sentMessageId = await gmailService.sendDraft(account, email.draft_id);
157
+ } else if (account.provider === 'outlook') {
158
+ const microsoftService = getMicrosoftService();
159
+ sentMessageId = await microsoftService.sendDraft(account, email.draft_id);
160
+ }
161
+ logger.info('Sent existing draft', { draftId: email.draft_id, sentMessageId });
162
+ }
163
+ // Fallback: Create new reply using content (if draft object was deleted externally or not saved)
164
+ else {
165
+ if (account.provider === 'gmail') {
166
+ const gmailService = getGmailService();
167
+ // Send reply
168
+ sentMessageId = await gmailService.sendReply(
169
+ account,
170
+ replyToId,
171
+ draftContent,
172
+ email.subject || ''
173
+ );
174
+ } else if (account.provider === 'outlook') {
175
+ const microsoftService = getMicrosoftService();
176
+ sentMessageId = await microsoftService.sendReply(
177
+ account,
178
+ replyToId,
179
+ draftContent
180
+ );
181
+ }
182
+ logger.info('Sent draft via reply', { sentMessageId });
183
+ }
184
+
185
+ // Update email status
186
+ await req.supabase!
187
+ .from('emails')
188
+ .update({
189
+ draft_status: 'sent',
190
+ draft_sent_at: new Date().toISOString(),
191
+ action_taken: 'reply', // Legacy compatibility
192
+ actions_taken: ['reply']
193
+ })
194
+ .eq('id', emailId);
195
+
196
+ res.json({ success: true, messageId: sentMessageId || email.draft_id });
197
+
198
+ } catch (error: any) {
199
+ logger.error('Failed to send draft', error, { emailId });
200
+ res.status(500).json({
201
+ error: 'Failed to send draft',
202
+ details: error.message
203
+ });
204
+ }
205
+ })
206
+ );
207
+
208
+ // Dismiss draft
209
+ router.post('/:emailId/dismiss',
210
+ apiRateLimit,
211
+ authMiddleware,
212
+ asyncHandler(async (req, res) => {
213
+ const { emailId } = req.params;
214
+
215
+ // Verify ownership
216
+ const { data: email, error } = await req.supabase!
217
+ .from('emails')
218
+ .select('id, email_accounts!inner(user_id)')
219
+ .eq('id', emailId)
220
+ .eq('email_accounts.user_id', req.user!.id)
221
+ .single();
222
+
223
+ if (error || !email) {
224
+ throw new NotFoundError('Email');
225
+ }
226
+
227
+ // Update status to dismissed
228
+ const { error: updateError } = await req.supabase!
229
+ .from('emails')
230
+ .update({
231
+ draft_status: 'dismissed',
232
+ draft_dismissed_at: new Date().toISOString()
233
+ })
234
+ .eq('id', emailId);
235
+
236
+ if (updateError) throw updateError;
237
+
238
+ res.json({ success: true });
239
+ })
240
+ );
241
+
242
+ export default router;
@@ -9,6 +9,7 @@ import emailsRoutes from './emails.js';
9
9
  import migrateRoutes from './migrate.js';
10
10
  import sdkRoutes from './sdk.js';
11
11
  import rulePacksRoutes from './rulePacks.js';
12
+ import draftsRoutes from './drafts.js';
12
13
 
13
14
  const router = Router();
14
15
 
@@ -22,5 +23,6 @@ router.use('/emails', emailsRoutes);
22
23
  router.use('/migrate', migrateRoutes);
23
24
  router.use('/sdk', sdkRoutes);
24
25
  router.use('/rule-packs', rulePacksRoutes);
26
+ router.use('/drafts', draftsRoutes);
25
27
 
26
28
  export default router;
@@ -413,24 +413,87 @@ export class GmailService {
413
413
  }
414
414
  }
415
415
 
416
- async sendDraft(account: EmailAccount, draftId: string): Promise<void> {
416
+ async sendDraft(account: EmailAccount, draftId: string): Promise<string> {
417
417
  const gmail = await this.getAuthenticatedClient(account);
418
418
 
419
419
  try {
420
- await gmail.users.drafts.send({
420
+ const response = await gmail.users.drafts.send({
421
421
  userId: 'me',
422
422
  requestBody: {
423
423
  id: draftId,
424
424
  },
425
425
  });
426
426
 
427
- logger.info('Draft sent successfully', { draftId });
427
+ const sentMessageId = response.data.id || draftId; // Fallback to draftId if no DB ID returned (rare)
428
+ logger.info('Draft sent successfully', { draftId, sentMessageId });
429
+ return sentMessageId;
428
430
  } catch (error) {
429
431
  logger.error('Gmail API Error sending draft', error);
430
432
  throw error;
431
433
  }
432
434
  }
433
435
 
436
+ /**
437
+ * Send a reply to a thread (used for sending drafts directly without creating a draft object first)
438
+ */
439
+ async sendReply(
440
+ account: EmailAccount,
441
+ originalMessageId: string,
442
+ replyContent: string,
443
+ subject: string
444
+ ): Promise<string> {
445
+ const gmail = await this.getAuthenticatedClient(account);
446
+
447
+ //Fetch original to get threadId and Message-ID
448
+ const original = await gmail.users.messages.get({ userId: 'me', id: originalMessageId });
449
+ const headers = original.data.payload?.headers || [];
450
+ const getHeader = (name: string) => headers.find(h => h.name?.toLowerCase() === name.toLowerCase())?.value || '';
451
+
452
+ const toAddress = getHeader('From');
453
+ const originalMsgId = getHeader('Message-ID');
454
+ const threadId = original.data.threadId;
455
+
456
+ // Threading headers
457
+ const replyHeaders = [];
458
+ if (originalMsgId) {
459
+ replyHeaders.push(`In-Reply-To: ${originalMsgId}`);
460
+ replyHeaders.push(`References: ${originalMsgId}`);
461
+ }
462
+
463
+ const rawMessage = [
464
+ `To: ${toAddress}`,
465
+ `Subject: ${subject}`,
466
+ ...replyHeaders,
467
+ 'MIME-Version: 1.0',
468
+ 'Content-Type: text/plain; charset="UTF-8"',
469
+ '',
470
+ replyContent,
471
+ ].join('\r\n');
472
+
473
+ const encodedMessage = Buffer.from(rawMessage)
474
+ .toString('base64')
475
+ .replace(/\+/g, '-')
476
+ .replace(/\//g, '_')
477
+ .replace(/=+$/, '');
478
+
479
+ try {
480
+ const result = await gmail.users.messages.send({
481
+ userId: 'me',
482
+ requestBody: {
483
+ threadId,
484
+ raw: encodedMessage,
485
+ },
486
+ });
487
+
488
+ const messageId = result.data.id || 'unknown';
489
+ logger.info('Reply sent successfully', { messageId, threadId });
490
+ return messageId;
491
+ } catch (error) {
492
+ logger.error('Gmail API Error sending reply', error);
493
+ throw error;
494
+ }
495
+ }
496
+
434
497
 
435
498
  async addLabel(account: EmailAccount, messageId: string, labelIds: string[]): Promise<void> {
436
499
  const gmail = await this.getAuthenticatedClient(account);
@@ -84,6 +84,9 @@ export interface EmailContext {
84
84
  userPreferences?: {
85
85
  autoTrashSpam?: boolean;
86
86
  smartDrafts?: boolean;
87
+ categoryPatterns?: Record<string, string>;
88
+ vipSenders?: string[];
89
+ preferredLength?: number;
87
90
  };
88
91
  }
89
92
 
@@ -121,8 +124,58 @@ export class IntelligenceService {
121
124
  if (context.metadata?.autoSubmitted && context.metadata.autoSubmitted !== 'no') metadataSignals.push(`- Auto-Submitted: ${context.metadata.autoSubmitted}`);
122
125
  if (context.metadata?.importance) metadataSignals.push(`- Priority: ${context.metadata.importance}`);
123
126
 
127
+ // Adaptive Learning Signals
128
+ if (context.userPreferences?.vipSenders?.includes(context.sender)) {
129
+ metadataSignals.push('- SENDER IS VIP (High Priority Required)');
130
+ }
131
+
132
+ const senderDomain = context.sender.split('@')[1];
133
+ if (senderDomain && context.userPreferences?.categoryPatterns?.[senderDomain]) {
134
+ const learnedCategory = context.userPreferences.categoryPatterns[senderDomain];
135
+ metadataSignals.push(`- LEARNED PATTERN: Sender domain '${senderDomain}' is strictly category '${learnedCategory}'`);
136
+ }
137
+
124
138
  const systemPrompt = `You are an AI Email Assistant. Analyze the email and return structured JSON.
125
- Definitions for Categories: spam, newsletter, promotional, transactional, social, support, client, internal, personal, other.
139
+
140
+ CATEGORY DEFINITIONS:
141
+ - spam: Unwanted/unsolicited bulk email, phishing attempts
142
+ - newsletter: Recurring subscription content (weekly digests, company updates) - check for List-Unsubscribe
143
+ - news: Breaking news alerts, timely notifications (Google Alerts, news feeds)
144
+ - promotional: Marketing emails, sales offers, advertisements
145
+ - transactional: Receipts, confirmations, order updates, account notifications
146
+ - social: Social media notifications (LinkedIn, Twitter, Facebook)
147
+ - support: Customer service, help desk, support tickets
148
+ - client: Business correspondence from clients/customers (High Importance)
149
+ - internal: Company-internal communications (colleagues, HR, IT)
150
+ - personal: Personal correspondence from friends/family
151
+ - notification: Platform alerts/notifications (Github, Linear, etc) - distinct from social
152
+ - other: Anything that doesn't fit above categories
153
+
154
+ CRITICAL RULES:
155
+ 1. Platform notifications (linkedin.com, github.com) are ALWAYS "notification" or "social", never "personal"
156
+ 2. Emails from noreply@, no-reply@ are likely "transactional" or "notification"
157
+ 3. Weekly/Monthly digests are "newsletter"
158
+ 4. If "List-Unsubscribe" header is present, it is likely "newsletter" or "promotional"
159
+ 5. Follow "LEARNED PATTERN" signals strictly if present
160
+ 6. VIP Senders must be "High" priority unless irrelevant (e.g. OOO auto-reply)
161
+
162
+ FEW-SHOT EXAMPLES:
163
+
164
+ Example 1: LinkedIn Connection
165
+ Subject: Canh Le wants to connect
166
+ From: messages-noreply@linkedin.com
167
+ Signals: [List-Unsubscribe]
168
+ -> { "category": "social", "is_useless": true, "priority": "Low", "suggested_actions": ["archive"] }
169
+
170
+ Example 2: Cold Sales
171
+ Subject: Boost your productivity
172
+ From: sales@unknown-vendor.com
173
+ -> { "category": "spam", "is_useless": true, "priority": "Low", "suggested_actions": ["delete"] }
174
+
175
+ Example 3: Client Question
176
+ Subject: Question about the contract
177
+ From: client@valued-customer.com
178
+ -> { "category": "client", "priority": "High", "is_useless": false, "suggested_actions": ["reply", "flag"] }
126
179
 
127
180
  Context:
128
181
  - Subject: ${context.subject}
@@ -220,6 +273,15 @@ REQUIRED JSON STRUCTURE:
220
273
  myName?: string;
221
274
  myRole?: string;
222
275
  myCompany?: string;
276
+ myIndustry?: string;
277
+ workStyle?: 'corporate' | 'startup' | 'creative' | 'academic';
278
+ communicationStyle?: {
279
+ tone?: string;
280
+ length?: string;
281
+ signature?: string;
282
+ commonPhrases?: string[];
283
+ };
284
+ primaryGoal?: string;
223
285
 
224
286
  // Email analysis metadata
225
287
  category?: string;
@@ -254,6 +316,9 @@ REQUIRED JSON STRUCTURE:
254
316
  if (richContext.myCompany) {
255
317
  systemPrompt += ` at ${richContext.myCompany}`;
256
318
  }
319
+ if (richContext.myIndustry) {
320
+ systemPrompt += ` (${richContext.myIndustry} industry)`;
321
+ }
257
322
  systemPrompt += '.';
258
323
  }
259
324
 
@@ -277,7 +342,55 @@ REQUIRED JSON STRUCTURE:
277
342
  systemPrompt += `\n\nIMPORTANT: The incoming email is written in ${richContext.language}. You MUST write your reply in ${richContext.language}. Maintain appropriate formality and cultural conventions for ${richContext.language}.`;
278
343
  }
279
344
 
280
- systemPrompt += '\n\nWrite ONLY the email body (no subject line). Be natural, concise, and professional. Match the tone of the incoming email.';
345
+ // Add persona-specific instructions
346
+ if (richContext?.workStyle) {
347
+ const styles = {
348
+ corporate: "Maintain a formal, structured, and polite tone.",
349
+ startup: "Be direct, concise, and action-oriented. Avoid fluff.",
350
+ creative: "Be expressive, flexible, and approachable.",
351
+ academic: "Be thorough, precise, and formal."
352
+ };
353
+ systemPrompt += `\nYour work style is: ${richContext.workStyle}. ${styles[richContext.workStyle as keyof typeof styles] || ''}`;
354
+ }
355
+
356
+ // Communication Preferences
357
+ if (richContext?.communicationStyle) {
358
+ const { tone, length, commonPhrases } = richContext.communicationStyle;
359
+
360
+ if (tone) systemPrompt += `\nPreferred Tone: ${tone}.`;
361
+
362
+ if (length) {
363
+ const lengths = {
364
+ brief: "Keep the reply very short (1-2 sentences).",
365
+ medium: "Keep the reply distinct and focused (2-3 paragraphs max).",
366
+ detailed: "Provide a comprehensive and detailed response."
367
+ };
368
+ systemPrompt += `\nResponse Length: ${lengths[length as keyof typeof lengths] || 'Medium'}.`;
369
+ }
370
+
371
+ if (commonPhrases && commonPhrases.length > 0) {
372
+ systemPrompt += `\nincorporate these phrases if natural: ${commonPhrases.join(', ')}.`;
373
+ }
374
+ }
375
+
376
+ // Goal Alignment
377
+ if (richContext?.primaryGoal) {
378
+ const goals = {
379
+ inbox_zero: "Goal: Clear the inbox. Resolve efficiently.",
380
+ respond_faster: "Goal: Quick acknowledgment or resolution.",
381
+ focus: "Goal: Protect user's time. Defer low-priority items.",
382
+ reduce_time: "Goal: Minimal user editing required. Draft ready-to-send."
383
+ };
384
+ systemPrompt += `\n${goals[richContext.primaryGoal as keyof typeof goals] || ''}`;
385
+ }
386
+
387
+ systemPrompt += '\n\nWrite ONLY the email body (no subject line). Match the tone of the incoming email unless overridden by preferences.';
388
+ systemPrompt += '\n\nSTYLE GUIDELINES:';
389
+ systemPrompt += '\n- Start directly (e.g., "Thanks for the update" or "Hi [Name],")';
390
+ systemPrompt += '\n- NEVER use robotic phrases like "I hope this email finds you well" or "I am writing to you today"';
391
+ systemPrompt += '\n- Avoid "Please let me know if you have any questions" unless necessary - just say "Let me know if you need anything else"';
392
+ systemPrompt += '\n- Keep it under 150 words unless detail is explicitly requested';
393
+ systemPrompt += '\n- Use active voice';
281
394
 
282
395
  // Build user message with email context
283
396
  let userMessage = '';
@@ -357,13 +470,27 @@ REQUIRED JSON STRUCTURE:
357
470
 
358
471
  const systemPrompt = `You are an AI Automation Agent. Analyze the email and identify ALL rules that apply.
359
472
 
473
+ CATEGORY DEFINITIONS:
474
+ - spam: Unwanted/unsolicited bulk email, phishing attempts
475
+ - newsletter: Recurring subscription content (weekly digests, company updates) - check for List-Unsubscribe
476
+ - news: Breaking news alerts, timely notifications (Google Alerts, news feeds)
477
+ - promotional: Marketing emails, sales offers, advertisements
478
+ - transactional: Receipts, confirmations, order updates, account notifications
479
+ - social: Social media notifications (LinkedIn, Twitter, Facebook)
480
+ - support: Customer service, help desk, support tickets
481
+ - client: Business correspondence from clients/customers (High Importance)
482
+ - internal: Company-internal communications (colleagues, HR, IT)
483
+ - personal: Personal correspondence from friends/family
484
+ - notification: Platform alerts/notifications (Github, Linear, etc) - distinct from social
485
+ - other: Anything that doesn't fit above categories
486
+
360
487
  Rules Context:
361
488
  ${rulesContext}
362
489
 
363
490
  REQUIRED JSON STRUCTURE:
364
491
  {
365
492
  "summary": "A brief summary of the email content",
366
- "category": "spam|newsletter|news|promotional|transactional|social|support|client|internal|personal|other",
493
+ "category": "spam|newsletter|news|promotional|transactional|social|support|client|internal|personal|notification|other",
367
494
  "priority": "High|Medium|Low",
368
495
  "matched_rules": [
369
496
  {
@@ -377,26 +504,15 @@ REQUIRED JSON STRUCTURE:
377
504
  "draft_content": "Suggested reply if drafting, otherwise null"
378
505
  }
379
506
 
380
- CATEGORY DEFINITIONS (choose the most specific):
381
- - spam: Unwanted/unsolicited bulk email, phishing attempts
382
- - newsletter: Recurring subscription content (weekly digests, company updates)
383
- - news: Breaking news alerts, timely notifications, one-off news items (Google Alerts, news feeds)
384
- - promotional: Marketing emails, sales offers, advertisements
385
- - transactional: Receipts, confirmations, order updates, account notifications
386
- - social: Social media notifications (LinkedIn, Twitter, Facebook)
387
- - support: Customer service, help desk, support tickets
388
- - client: Business correspondence from clients/customers
389
- - internal: Company-internal communications (colleagues, HR, IT)
390
- - personal: Personal correspondence from friends/family
391
- - other: Anything that doesn't fit above categories
392
-
393
507
  CRITICAL INSTRUCTIONS:
394
508
  - Identify ALL rules that apply to this email (not just the best one)
395
509
  - Return an empty array if no rules match
396
510
  - Only include rules with confidence >= 0.7
397
511
  - For each matched rule, explain why it applies
398
512
  - Actions will be merged by the system - you don't need to resolve conflicts
399
- - Use "draft" action only if a rule explicitly requests it`;
513
+ - Use "draft" action only if a rule explicitly requests it
514
+ - Platform notifications (linkedin, github) are ALWAYS "notification" or "social"
515
+ - Emails from noreply@ are likely "transactional" or "notification"`;
400
516
 
401
517
  if (eventLogger) {
402
518
  await eventLogger.info('Thinking', `Context-aware analysis: ${context.subject}`, {