@realtimex/email-automator 2.2.1 → 2.3.1

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.
@@ -0,0 +1,243 @@
1
+ import OpenAI from 'openai';
2
+ import Instructor from '@instructor-ai/instructor';
3
+ import { z } from 'zod';
4
+ import { config } from '../config/index.js';
5
+ import { createLogger } from '../utils/logger.js';
6
+ import { ContentCleaner } from '../utils/contentCleaner.js';
7
+ const logger = createLogger('Intelligence');
8
+ // Define the schema for email analysis
9
+ export const EmailAnalysisSchema = z.object({
10
+ summary: z.string().describe('A brief summary of the email content'),
11
+ category: z.enum(['spam', 'newsletter', 'promotional', 'transactional', 'social', 'support', 'client', 'internal', 'personal', 'other'])
12
+ .describe('The category of the email'),
13
+ sentiment: z.enum(['Positive', 'Neutral', 'Negative'])
14
+ .describe('The emotional tone of the email'),
15
+ is_useless: z.boolean()
16
+ .describe('Whether the email is considered useless (spam, newsletter, etc.)'),
17
+ suggested_actions: z.array(z.enum(['none', 'delete', 'archive', 'reply', 'flag']))
18
+ .describe('The recommended next actions (e.g. ["reply", "archive"])'),
19
+ draft_response: z.string().optional()
20
+ .describe('A suggested draft response if the action is reply'),
21
+ priority: z.enum(['High', 'Medium', 'Low'])
22
+ .describe('The urgency of the email'),
23
+ key_points: z.array(z.string()).optional()
24
+ .describe('Key points extracted from the email'),
25
+ action_items: z.array(z.string()).optional()
26
+ .describe('Action items mentioned in the email'),
27
+ });
28
+ export class IntelligenceService {
29
+ client;
30
+ model;
31
+ isConfigured = false;
32
+ constructor(overrides) {
33
+ const apiKey = overrides?.apiKey || config.llm.apiKey;
34
+ const baseUrl = overrides?.baseUrl || config.llm.baseUrl;
35
+ this.model = overrides?.model || config.llm.model;
36
+ // Allow local LLM servers (LM Studio, Ollama) or custom endpoints that don't need API keys
37
+ // We assume any custom baseUrl might be a local/private instance.
38
+ const isCustomEndpoint = baseUrl && !baseUrl.includes('api.openai.com');
39
+ if (!apiKey && !isCustomEndpoint) {
40
+ logger.warn('LLM_API_KEY is missing and no custom LLM endpoint configured. AI analysis will not work.');
41
+ return;
42
+ }
43
+ try {
44
+ const oai = new OpenAI({
45
+ apiKey: apiKey || 'not-needed-for-local', // Placeholder for local LLMs
46
+ baseURL: baseUrl,
47
+ });
48
+ this.client = Instructor({
49
+ client: oai,
50
+ mode: 'MD_JSON',
51
+ });
52
+ this.isConfigured = true;
53
+ logger.info('Intelligence service initialized', { model: this.model, baseUrl: baseUrl || 'default' });
54
+ }
55
+ catch (error) {
56
+ console.error('[Intelligence] Init failed:', error);
57
+ logger.error('Failed to initialize Intelligence service', error);
58
+ }
59
+ }
60
+ isReady() {
61
+ const ready = this.isConfigured && !!this.client;
62
+ if (!ready)
63
+ console.log('[Intelligence] isReady check failed:', { isConfigured: this.isConfigured, hasClient: !!this.client });
64
+ return ready;
65
+ }
66
+ async analyzeEmail(content, context, eventLogger, emailId) {
67
+ console.log('[Intelligence] analyzeEmail called for:', context.subject);
68
+ if (!this.isReady()) {
69
+ console.log('[Intelligence] Not ready, skipping');
70
+ logger.warn('Intelligence service not ready, skipping analysis');
71
+ if (eventLogger) {
72
+ await eventLogger.info('Skipped', 'AI Analysis skipped: Model not configured. Please check settings.', undefined, emailId);
73
+ }
74
+ return null;
75
+ }
76
+ // 1. Prepare Content and Signals
77
+ const cleanedContent = ContentCleaner.cleanEmailBody(content).substring(0, 2500);
78
+ const metadataSignals = [];
79
+ if (context.metadata?.listUnsubscribe)
80
+ metadataSignals.push('- Contains Unsubscribe header (High signal for Newsletter/Promo)');
81
+ if (context.metadata?.autoSubmitted && context.metadata.autoSubmitted !== 'no')
82
+ metadataSignals.push(`- Auto-Submitted: ${context.metadata.autoSubmitted}`);
83
+ if (context.metadata?.importance)
84
+ metadataSignals.push(`- Sender Priority/Importance: ${context.metadata.importance}`);
85
+ if (context.metadata?.mailer)
86
+ metadataSignals.push(`- Sent via: ${context.metadata.mailer}`);
87
+ const systemPrompt = `You are an AI Email Assistant. Your task is to analyze the provided email and extract structured information as JSON.
88
+ Do NOT include any greetings, chatter, or special tokens like <|channel|> in your output.
89
+ Return ONLY a valid JSON object.
90
+
91
+ Definitions for Categories:
92
+ - "important": Work-related, urgent, from known contacts
93
+ - "promotional": Marketing, sales, discounts
94
+ - "transactional": Receipts, shipping, confirmations
95
+ - "social": LinkedIn, friends, social updates
96
+ - "newsletter": Subscribed content
97
+ - "spam": Junk, suspicious
98
+
99
+ Context:
100
+ - Current Date: ${new Date().toISOString()}
101
+ - Subject: ${context.subject}
102
+ - From: ${context.sender}
103
+ - Date: ${context.date}
104
+ ${metadataSignals.length > 0 ? `\nMetadata Signals:\n${metadataSignals.join('\n')}` : ''}
105
+ ${context.userPreferences?.autoTrashSpam ? '- User has auto-trash spam enabled' : ''}
106
+ ${context.userPreferences?.smartDrafts ? '- User wants draft responses for important emails' : ''}
107
+
108
+ REQUIRED JSON STRUCTURE:
109
+ {
110
+ "summary": "string",
111
+ "category": "spam|newsletter|promotional|transactional|social|support|client|internal|personal|other",
112
+ "sentiment": "Positive|Neutral|Negative",
113
+ "is_useless": boolean,
114
+ "suggested_actions": ["none"|"delete"|"archive"|"reply"|"flag"],
115
+ "draft_response": "string (optional)",
116
+ "priority": "High|Medium|Low",
117
+ "key_points": ["string"],
118
+ "action_items": ["string"]
119
+ }`;
120
+ // 2. Log Thinking Phase
121
+ if (eventLogger) {
122
+ console.log('[Intelligence] Logging "Thinking" event');
123
+ try {
124
+ await eventLogger.info('Thinking', `Analyzing email: ${context.subject}`, {
125
+ model: this.model,
126
+ system_prompt: systemPrompt,
127
+ content_preview: cleanedContent
128
+ }, emailId);
129
+ }
130
+ catch (err) {
131
+ console.error('[Intelligence] Failed to log thinking event:', err);
132
+ }
133
+ }
134
+ let rawResponse = '';
135
+ try {
136
+ // Using raw completion call to handle garbage characters and strip tokens manually
137
+ const response = await this.client.client.chat.completions.create({
138
+ model: this.model,
139
+ messages: [
140
+ { role: 'system', content: systemPrompt },
141
+ { role: 'user', content: cleanedContent || '[Empty email body]' },
142
+ ],
143
+ temperature: 0.1,
144
+ });
145
+ rawResponse = response.choices[0]?.message?.content || '';
146
+ console.log('[Intelligence] Raw LLM Response received (length:', rawResponse.length, ')');
147
+ // Clean the response: Find first '{' and last '}'
148
+ let jsonStr = rawResponse.trim();
149
+ const startIdx = jsonStr.indexOf('{');
150
+ const endIdx = jsonStr.lastIndexOf('}');
151
+ if (startIdx === -1 || endIdx === -1) {
152
+ throw new Error('Response did not contain a valid JSON object (missing curly braces)');
153
+ }
154
+ jsonStr = jsonStr.substring(startIdx, endIdx + 1);
155
+ const parsed = JSON.parse(jsonStr);
156
+ const validated = EmailAnalysisSchema.parse(parsed);
157
+ logger.debug('Email analyzed', {
158
+ category: validated.category,
159
+ actions: validated.suggested_actions,
160
+ });
161
+ if (eventLogger && emailId) {
162
+ await eventLogger.analysis('Decided', emailId, {
163
+ ...validated,
164
+ _raw_response: rawResponse
165
+ });
166
+ }
167
+ return validated;
168
+ }
169
+ catch (error) {
170
+ console.error('[Intelligence] AI Analysis failed:', error);
171
+ if (eventLogger) {
172
+ await eventLogger.error('Error', {
173
+ error: error instanceof Error ? error.message : String(error),
174
+ raw_response: rawResponse || 'No response received from LLM'
175
+ }, emailId);
176
+ }
177
+ return null;
178
+ }
179
+ }
180
+ async generateDraftReply(originalEmail, instructions) {
181
+ if (!this.isReady()) {
182
+ return null;
183
+ }
184
+ try {
185
+ const response = await this.client.chat.completions.create({
186
+ model: this.model,
187
+ messages: [
188
+ {
189
+ role: 'system',
190
+ content: `You are a professional email assistant. Generate a polite and appropriate reply to the email.
191
+ ${instructions ? `Additional instructions: ${instructions}` : ''}
192
+ Keep the response concise and professional.`,
193
+ },
194
+ {
195
+ role: 'user',
196
+ content: `Original email from ${originalEmail.sender}:
197
+ Subject: ${originalEmail.subject}
198
+
199
+ ${originalEmail.body}
200
+
201
+ Please write a reply.`,
202
+ },
203
+ ],
204
+ });
205
+ return response.choices[0]?.message?.content || null;
206
+ }
207
+ catch (error) {
208
+ logger.error('Draft generation failed', error);
209
+ return null;
210
+ }
211
+ }
212
+ async testConnection() {
213
+ if (!this.isReady()) {
214
+ return { success: false, message: 'Intelligence service not initialized. Check your API Key.' };
215
+ }
216
+ try {
217
+ await this.client.chat.completions.create({
218
+ model: this.model,
219
+ messages: [{ role: 'user', content: 'Say "Connection Successful"' }],
220
+ max_tokens: 5,
221
+ });
222
+ return { success: true, message: 'Connection successful!' };
223
+ }
224
+ catch (error) {
225
+ logger.error('Connection test failed', error);
226
+ return {
227
+ success: false,
228
+ message: error instanceof Error ? error.message : 'Unknown error'
229
+ };
230
+ }
231
+ }
232
+ }
233
+ // Singleton instance with default config
234
+ let defaultInstance = null;
235
+ export function getIntelligenceService(overrides) {
236
+ if (overrides) {
237
+ return new IntelligenceService(overrides);
238
+ }
239
+ if (!defaultInstance) {
240
+ defaultInstance = new IntelligenceService();
241
+ }
242
+ return defaultInstance;
243
+ }
@@ -0,0 +1,256 @@
1
+ import * as msal from '@azure/msal-node';
2
+ import { config } from '../config/index.js';
3
+ import { createLogger } from '../utils/logger.js';
4
+ const logger = createLogger('MicrosoftService');
5
+ const GRAPH_SCOPES = [
6
+ 'https://graph.microsoft.com/Mail.Read',
7
+ 'https://graph.microsoft.com/Mail.ReadWrite',
8
+ 'https://graph.microsoft.com/User.Read',
9
+ ];
10
+ export class MicrosoftService {
11
+ pca;
12
+ cca = null;
13
+ constructor() {
14
+ const publicConfig = {
15
+ auth: {
16
+ clientId: config.microsoft.clientId,
17
+ authority: `https://login.microsoftonline.com/${config.microsoft.tenantId}`,
18
+ },
19
+ };
20
+ this.pca = new msal.PublicClientApplication(publicConfig);
21
+ // Confidential client for server-side token refresh
22
+ if (config.microsoft.clientSecret) {
23
+ const confidentialConfig = {
24
+ auth: {
25
+ clientId: config.microsoft.clientId,
26
+ authority: `https://login.microsoftonline.com/${config.microsoft.tenantId}`,
27
+ clientSecret: config.microsoft.clientSecret,
28
+ },
29
+ };
30
+ this.cca = new msal.ConfidentialClientApplication(confidentialConfig);
31
+ }
32
+ }
33
+ async initiateDeviceCodeFlow() {
34
+ const deviceCodeRequest = {
35
+ scopes: GRAPH_SCOPES,
36
+ deviceCodeCallback: (response) => {
37
+ logger.info('Device code received', { userCode: response.userCode });
38
+ },
39
+ };
40
+ const response = await this.pca.acquireTokenByDeviceCode(deviceCodeRequest);
41
+ // The device code flow returns tokens directly after user completes auth
42
+ // For now, we return the device code info for the frontend to display
43
+ return {
44
+ userCode: '',
45
+ verificationUri: 'https://microsoft.com/devicelogin',
46
+ message: 'Please visit https://microsoft.com/devicelogin and enter the code shown',
47
+ expiresIn: 900,
48
+ interval: 5,
49
+ deviceCode: '',
50
+ };
51
+ }
52
+ async acquireTokenByDeviceCode(deviceCodeCallback) {
53
+ try {
54
+ const response = await this.pca.acquireTokenByDeviceCode({
55
+ scopes: GRAPH_SCOPES,
56
+ deviceCodeCallback,
57
+ });
58
+ return response;
59
+ }
60
+ catch (error) {
61
+ logger.error('Device code flow failed', error);
62
+ return null;
63
+ }
64
+ }
65
+ async saveAccount(supabase, userId, emailAddress, authResult) {
66
+ const { data, error } = await supabase
67
+ .from('email_accounts')
68
+ .upsert({
69
+ user_id: userId,
70
+ email_address: emailAddress,
71
+ provider: 'outlook',
72
+ access_token: authResult.accessToken,
73
+ refresh_token: null, // MSAL handles token cache internally
74
+ token_expires_at: authResult.expiresOn?.toISOString() || null,
75
+ scopes: authResult.scopes,
76
+ is_active: true,
77
+ updated_at: new Date().toISOString(),
78
+ }, { onConflict: 'user_id, email_address' })
79
+ .select()
80
+ .single();
81
+ if (error)
82
+ throw error;
83
+ return data;
84
+ }
85
+ async refreshTokenIfNeeded(supabase, account) {
86
+ if (!account.token_expires_at)
87
+ return account;
88
+ const expiresAt = new Date(account.token_expires_at).getTime();
89
+ const now = Date.now();
90
+ const bufferMs = 5 * 60 * 1000;
91
+ if (expiresAt > now + bufferMs) {
92
+ return account;
93
+ }
94
+ logger.info('Refreshing Microsoft token', { accountId: account.id });
95
+ // For Microsoft, we need the CCA with client secret for refresh
96
+ if (!this.cca) {
97
+ throw new Error('Microsoft client secret not configured for token refresh');
98
+ }
99
+ // In production, you'd use refresh tokens stored in a token cache
100
+ // This is a simplified implementation
101
+ throw new Error('Microsoft token refresh requires re-authentication');
102
+ }
103
+ async fetchMessages(account, options = {}) {
104
+ const accessToken = account.access_token || '';
105
+ const { top = 20, skip = 0, filter } = options;
106
+ let url = `https://graph.microsoft.com/v1.0/me/messages?$top=${top}&$skip=${skip}&$orderby=receivedDateTime desc&$select=id,conversationId,subject,from,toRecipients,receivedDateTime,body,bodyPreview,importance`;
107
+ if (filter) {
108
+ url += `&$filter=${encodeURIComponent(filter)}`;
109
+ }
110
+ const response = await fetch(url, {
111
+ headers: {
112
+ Authorization: `Bearer ${accessToken}`,
113
+ 'Content-Type': 'application/json',
114
+ },
115
+ });
116
+ if (!response.ok) {
117
+ const error = await response.text();
118
+ logger.error('Failed to fetch Outlook messages', new Error(error));
119
+ throw new Error('Failed to fetch messages from Outlook');
120
+ }
121
+ const data = await response.json();
122
+ const messages = (data.value || []).map((msg) => ({
123
+ id: msg.id,
124
+ conversationId: msg.conversationId,
125
+ subject: msg.subject || 'No Subject',
126
+ sender: msg.from?.emailAddress?.address || 'Unknown',
127
+ recipient: msg.toRecipients?.[0]?.emailAddress?.address || '',
128
+ date: msg.receivedDateTime,
129
+ body: msg.body?.content || '',
130
+ snippet: msg.bodyPreview || '',
131
+ headers: {
132
+ importance: msg.importance,
133
+ }
134
+ }));
135
+ return {
136
+ messages,
137
+ hasMore: !!data['@odata.nextLink'],
138
+ };
139
+ }
140
+ async trashMessage(account, messageId) {
141
+ const accessToken = account.access_token || '';
142
+ const response = await fetch(`https://graph.microsoft.com/v1.0/me/messages/${messageId}/move`, {
143
+ method: 'POST',
144
+ headers: {
145
+ Authorization: `Bearer ${accessToken}`,
146
+ 'Content-Type': 'application/json',
147
+ },
148
+ body: JSON.stringify({ destinationId: 'deleteditems' }),
149
+ });
150
+ if (!response.ok) {
151
+ throw new Error('Failed to trash message');
152
+ }
153
+ logger.debug('Outlook message trashed', { messageId });
154
+ }
155
+ async archiveMessage(account, messageId) {
156
+ const accessToken = account.access_token || '';
157
+ const response = await fetch(`https://graph.microsoft.com/v1.0/me/messages/${messageId}/move`, {
158
+ method: 'POST',
159
+ headers: {
160
+ Authorization: `Bearer ${accessToken}`,
161
+ 'Content-Type': 'application/json',
162
+ },
163
+ body: JSON.stringify({ destinationId: 'archive' }),
164
+ });
165
+ if (!response.ok) {
166
+ throw new Error('Failed to archive message');
167
+ }
168
+ logger.debug('Outlook message archived', { messageId });
169
+ }
170
+ async markAsRead(account, messageId) {
171
+ const accessToken = account.access_token || '';
172
+ await fetch(`https://graph.microsoft.com/v1.0/me/messages/${messageId}`, {
173
+ method: 'PATCH',
174
+ headers: {
175
+ Authorization: `Bearer ${accessToken}`,
176
+ 'Content-Type': 'application/json',
177
+ },
178
+ body: JSON.stringify({ isRead: true }),
179
+ });
180
+ logger.debug('Outlook message marked as read', { messageId });
181
+ }
182
+ async flagMessage(account, messageId) {
183
+ const accessToken = account.access_token || '';
184
+ await fetch(`https://graph.microsoft.com/v1.0/me/messages/${messageId}`, {
185
+ method: 'PATCH',
186
+ headers: {
187
+ Authorization: `Bearer ${accessToken}`,
188
+ 'Content-Type': 'application/json',
189
+ },
190
+ body: JSON.stringify({ flag: { flagStatus: 'flagged' } }),
191
+ });
192
+ logger.debug('Outlook message flagged', { messageId });
193
+ }
194
+ async createDraft(account, originalMessageId, replyContent) {
195
+ const accessToken = account.access_token || '';
196
+ // Get original message
197
+ const originalResponse = await fetch(`https://graph.microsoft.com/v1.0/me/messages/${originalMessageId}`, {
198
+ headers: {
199
+ Authorization: `Bearer ${accessToken}`,
200
+ },
201
+ });
202
+ const original = await originalResponse.json();
203
+ // Create reply draft
204
+ const response = await fetch(`https://graph.microsoft.com/v1.0/me/messages/${originalMessageId}/createReply`, {
205
+ method: 'POST',
206
+ headers: {
207
+ Authorization: `Bearer ${accessToken}`,
208
+ 'Content-Type': 'application/json',
209
+ },
210
+ });
211
+ if (!response.ok) {
212
+ throw new Error('Failed to create reply draft');
213
+ }
214
+ const draft = await response.json();
215
+ // Update draft with content
216
+ await fetch(`https://graph.microsoft.com/v1.0/me/messages/${draft.id}`, {
217
+ method: 'PATCH',
218
+ headers: {
219
+ Authorization: `Bearer ${accessToken}`,
220
+ 'Content-Type': 'application/json',
221
+ },
222
+ body: JSON.stringify({
223
+ body: {
224
+ contentType: 'text',
225
+ content: replyContent,
226
+ },
227
+ }),
228
+ });
229
+ logger.debug('Outlook draft created', { draftId: draft.id });
230
+ return draft.id;
231
+ }
232
+ async getProfile(account) {
233
+ const accessToken = account.access_token || '';
234
+ const response = await fetch('https://graph.microsoft.com/v1.0/me', {
235
+ headers: {
236
+ Authorization: `Bearer ${accessToken}`,
237
+ },
238
+ });
239
+ if (!response.ok) {
240
+ throw new Error('Failed to get profile');
241
+ }
242
+ const profile = await response.json();
243
+ return {
244
+ emailAddress: profile.mail || profile.userPrincipalName || '',
245
+ displayName: profile.displayName || '',
246
+ };
247
+ }
248
+ }
249
+ // Singleton
250
+ let instance = null;
251
+ export function getMicrosoftService() {
252
+ if (!instance) {
253
+ instance = new MicrosoftService();
254
+ }
255
+ return instance;
256
+ }