@realtimex/email-automator 2.10.6 → 2.10.8

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.
@@ -11,6 +11,9 @@ const GRAPH_SCOPES = [
11
11
  'https://graph.microsoft.com/Mail.Read',
12
12
  'https://graph.microsoft.com/Mail.ReadWrite',
13
13
  'https://graph.microsoft.com/User.Read',
14
+ 'offline_access',
15
+ 'openid',
16
+ 'profile',
14
17
  ];
15
18
 
16
19
  export interface OutlookMessage {
@@ -97,6 +100,9 @@ export class MicrosoftService {
97
100
  emailAddress: string,
98
101
  authResult: msal.AuthenticationResult
99
102
  ): Promise<EmailAccount> {
103
+ // MSAL Node doesn't expose refresh_token directly in AuthenticationResult
104
+ // but it is available if you use a cache plugin or direct refresh token request.
105
+ // For now, we'll store what we have.
100
106
  const { data, error } = await supabase
101
107
  .from('email_accounts')
102
108
  .upsert({
@@ -104,7 +110,7 @@ export class MicrosoftService {
104
110
  email_address: emailAddress,
105
111
  provider: 'outlook',
106
112
  access_token: authResult.accessToken,
107
- refresh_token: null, // MSAL handles token cache internally
113
+ refresh_token: (authResult as any).refreshToken || null,
108
114
  token_expires_at: authResult.expiresOn?.toISOString() || null,
109
115
  scopes: authResult.scopes,
110
116
  is_active: true,
@@ -117,6 +123,23 @@ export class MicrosoftService {
117
123
  return data;
118
124
  }
119
125
 
126
+ async getProviderCredentials(supabase: SupabaseClient, userId: string): Promise<{ clientId: string; clientSecret?: string; tenantId: string }> {
127
+ const { data: integration } = await supabase
128
+ .from('integrations')
129
+ .select('credentials')
130
+ .eq('user_id', userId)
131
+ .eq('provider', 'microsoft')
132
+ .single();
133
+
134
+ const creds = integration?.credentials as any;
135
+
136
+ return {
137
+ clientId: creds?.client_id || config.microsoft.clientId,
138
+ clientSecret: creds?.client_secret || config.microsoft.clientSecret,
139
+ tenantId: creds?.tenant_id || config.microsoft.tenantId || 'common'
140
+ };
141
+ }
142
+
120
143
  async refreshTokenIfNeeded(
121
144
  supabase: SupabaseClient,
122
145
  account: EmailAccount
@@ -133,14 +156,51 @@ export class MicrosoftService {
133
156
 
134
157
  logger.info('Refreshing Microsoft token', { accountId: account.id });
135
158
 
136
- // For Microsoft, we need the CCA with client secret for refresh
137
- if (!this.cca) {
138
- throw new Error('Microsoft client secret not configured for token refresh');
159
+ const refreshToken = account.refresh_token;
160
+
161
+ // If we have a refresh token stored, we can try to use the CCA
162
+ if (refreshToken) {
163
+ try {
164
+ const creds = await this.getProviderCredentials(supabase, account.user_id);
165
+ if (creds.clientSecret) {
166
+ const confidentialConfig: msal.Configuration = {
167
+ auth: {
168
+ clientId: creds.clientId,
169
+ authority: `https://login.microsoftonline.com/${creds.tenantId}`,
170
+ clientSecret: creds.clientSecret,
171
+ },
172
+ };
173
+ const cca = new msal.ConfidentialClientApplication(confidentialConfig);
174
+ const result = await cca.acquireTokenByRefreshToken({
175
+ refreshToken,
176
+ scopes: GRAPH_SCOPES,
177
+ });
178
+
179
+ if (result) {
180
+ const { data, error } = await supabase
181
+ .from('email_accounts')
182
+ .update({
183
+ access_token: result.accessToken,
184
+ refresh_token: (result as any).refreshToken || refreshToken, // Keep old if new not provided
185
+ token_expires_at: result.expiresOn?.toISOString() || null,
186
+ updated_at: new Date().toISOString(),
187
+ })
188
+ .eq('id', account.id)
189
+ .select()
190
+ .single();
191
+
192
+ if (error) throw error;
193
+ return data;
194
+ }
195
+ }
196
+ } catch (err) {
197
+ logger.warn('Confidential refresh failed, attempting public refresh...', { error: err });
198
+ }
139
199
  }
140
200
 
141
- // In production, you'd use refresh tokens stored in a token cache
142
- // This is a simplified implementation
143
- throw new Error('Microsoft token refresh requires re-authentication');
201
+ // Fallback or if no refresh token: we can't refresh automatically without a persistent cache
202
+ // Modern MSAL usually requires a TokenCache implementation for this
203
+ throw new Error('Outlook session expired. Please reconnect your account in Settings.');
144
204
  }
145
205
 
146
206
  async fetchMessages(
@@ -66,6 +66,8 @@ export class EmailProcessorService {
66
66
  let refreshedAccount = account;
67
67
  if (account.provider === 'gmail') {
68
68
  refreshedAccount = await this.gmailService.refreshTokenIfNeeded(this.supabase, account);
69
+ } else if (account.provider === 'outlook') {
70
+ refreshedAccount = await this.microsoftService.refreshTokenIfNeeded(this.supabase, account);
69
71
  }
70
72
 
71
73
  // Update status to syncing
@@ -95,17 +97,22 @@ export class EmailProcessorService {
95
97
  if (eventLogger) await eventLogger.info('Running', 'Starting sync process');
96
98
 
97
99
  // Process based on provider
98
- if (refreshedAccount.provider === 'gmail') {
99
- await this.processGmailAccount(refreshedAccount, rules || [], settings, result, eventLogger);
100
- } else if (refreshedAccount.provider === 'outlook') {
101
- await this.processOutlookAccount(refreshedAccount, rules || [], settings, result, eventLogger);
100
+ try {
101
+ if (refreshedAccount.provider === 'gmail') {
102
+ await this.processGmailAccount(refreshedAccount, rules || [], settings, result, eventLogger);
103
+ } else if (refreshedAccount.provider === 'outlook') {
104
+ await this.processOutlookAccount(refreshedAccount, rules || [], settings, result, eventLogger);
105
+ }
106
+ } catch (providerError) {
107
+ const providerName = refreshedAccount.provider === 'gmail' ? 'Gmail' : 'Outlook';
108
+ throw new Error(`${providerName} Sync Error: ${providerError instanceof Error ? providerError.message : String(providerError)}`);
102
109
  }
103
110
 
104
111
  // After processing new emails, run retention rules for this account
105
112
  await this.runRetentionRules(refreshedAccount, rules || [], settings, result, eventLogger);
106
113
 
107
114
  // Wait for background worker to process the queue (ensure sync is fully complete before event)
108
- await this.processQueue(userId, settings).catch(err =>
115
+ await this.processQueue(userId, settings, result).catch(err =>
109
116
  logger.error('Background worker failed', err)
110
117
  );
111
118
 
@@ -484,7 +491,7 @@ export class EmailProcessorService {
484
491
  /**
485
492
  * Background Worker: Processes pending emails for a user recursively until empty.
486
493
  */
487
- async processQueue(userId: string, settings: any): Promise<void> {
494
+ async processQueue(userId: string, settings: any, result?: ProcessingResult): Promise<void> {
488
495
  logger.info('Worker: Checking queue', { userId });
489
496
 
490
497
  // Fetch up to 5 pending emails for this user
@@ -508,15 +515,15 @@ export class EmailProcessorService {
508
515
  logger.info('Worker: Processing batch', { userId, count: pendingEmails.length });
509
516
 
510
517
  for (const email of pendingEmails) {
511
- await this.processPendingEmail(email, userId, settings);
518
+ await this.processPendingEmail(email, userId, settings, result);
512
519
  }
513
520
 
514
521
  // Slight delay to prevent hitting rate limits too fast, then check again
515
522
  await new Promise(resolve => setTimeout(resolve, 1000));
516
- return this.processQueue(userId, settings);
523
+ return this.processQueue(userId, settings, result);
517
524
  }
518
525
 
519
- private async processPendingEmail(email: Email, userId: string, settings: any): Promise<void> {
526
+ private async processPendingEmail(email: Email, userId: string, settings: any, result?: ProcessingResult): Promise<void> {
520
527
  // Create a real processing log entry for this background task to ensure RLS compliance
521
528
  const { data: log } = await this.supabase
522
529
  .from('processing_logs')
@@ -701,6 +708,12 @@ export class EmailProcessorService {
701
708
  `Rule: ${matchedRule?.name || analysis.matched_rule.rule_name}`,
702
709
  matchedRule?.attachments
703
710
  );
711
+
712
+ // Update metrics if result object provided
713
+ if (result) {
714
+ if (action === 'delete') result.deleted++;
715
+ else if (action === 'draft') result.drafted++;
716
+ }
704
717
  }
705
718
  } else if (eventLogger && rules && rules.length > 0) {
706
719
  await eventLogger.info('No Match',
@@ -6,6 +6,9 @@ const GRAPH_SCOPES = [
6
6
  'https://graph.microsoft.com/Mail.Read',
7
7
  'https://graph.microsoft.com/Mail.ReadWrite',
8
8
  'https://graph.microsoft.com/User.Read',
9
+ 'offline_access',
10
+ 'openid',
11
+ 'profile',
9
12
  ];
10
13
  export class MicrosoftService {
11
14
  pca;
@@ -63,6 +66,9 @@ export class MicrosoftService {
63
66
  }
64
67
  }
65
68
  async saveAccount(supabase, userId, emailAddress, authResult) {
69
+ // MSAL Node doesn't expose refresh_token directly in AuthenticationResult
70
+ // but it is available if you use a cache plugin or direct refresh token request.
71
+ // For now, we'll store what we have.
66
72
  const { data, error } = await supabase
67
73
  .from('email_accounts')
68
74
  .upsert({
@@ -70,7 +76,7 @@ export class MicrosoftService {
70
76
  email_address: emailAddress,
71
77
  provider: 'outlook',
72
78
  access_token: authResult.accessToken,
73
- refresh_token: null, // MSAL handles token cache internally
79
+ refresh_token: authResult.refreshToken || null,
74
80
  token_expires_at: authResult.expiresOn?.toISOString() || null,
75
81
  scopes: authResult.scopes,
76
82
  is_active: true,
@@ -82,6 +88,20 @@ export class MicrosoftService {
82
88
  throw error;
83
89
  return data;
84
90
  }
91
+ async getProviderCredentials(supabase, userId) {
92
+ const { data: integration } = await supabase
93
+ .from('integrations')
94
+ .select('credentials')
95
+ .eq('user_id', userId)
96
+ .eq('provider', 'microsoft')
97
+ .single();
98
+ const creds = integration?.credentials;
99
+ return {
100
+ clientId: creds?.client_id || config.microsoft.clientId,
101
+ clientSecret: creds?.client_secret || config.microsoft.clientSecret,
102
+ tenantId: creds?.tenant_id || config.microsoft.tenantId || 'common'
103
+ };
104
+ }
85
105
  async refreshTokenIfNeeded(supabase, account) {
86
106
  if (!account.token_expires_at)
87
107
  return account;
@@ -92,13 +112,49 @@ export class MicrosoftService {
92
112
  return account;
93
113
  }
94
114
  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');
115
+ const refreshToken = account.refresh_token;
116
+ // If we have a refresh token stored, we can try to use the CCA
117
+ if (refreshToken) {
118
+ try {
119
+ const creds = await this.getProviderCredentials(supabase, account.user_id);
120
+ if (creds.clientSecret) {
121
+ const confidentialConfig = {
122
+ auth: {
123
+ clientId: creds.clientId,
124
+ authority: `https://login.microsoftonline.com/${creds.tenantId}`,
125
+ clientSecret: creds.clientSecret,
126
+ },
127
+ };
128
+ const cca = new msal.ConfidentialClientApplication(confidentialConfig);
129
+ const result = await cca.acquireTokenByRefreshToken({
130
+ refreshToken,
131
+ scopes: GRAPH_SCOPES,
132
+ });
133
+ if (result) {
134
+ const { data, error } = await supabase
135
+ .from('email_accounts')
136
+ .update({
137
+ access_token: result.accessToken,
138
+ refresh_token: result.refreshToken || refreshToken, // Keep old if new not provided
139
+ token_expires_at: result.expiresOn?.toISOString() || null,
140
+ updated_at: new Date().toISOString(),
141
+ })
142
+ .eq('id', account.id)
143
+ .select()
144
+ .single();
145
+ if (error)
146
+ throw error;
147
+ return data;
148
+ }
149
+ }
150
+ }
151
+ catch (err) {
152
+ logger.warn('Confidential refresh failed, attempting public refresh...', { error: err });
153
+ }
98
154
  }
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');
155
+ // Fallback or if no refresh token: we can't refresh automatically without a persistent cache
156
+ // Modern MSAL usually requires a TokenCache implementation for this
157
+ throw new Error('Outlook session expired. Please reconnect your account in Settings.');
102
158
  }
103
159
  async fetchMessages(account, options = {}) {
104
160
  const accessToken = account.access_token || '';
@@ -49,6 +49,9 @@ export class EmailProcessorService {
49
49
  if (account.provider === 'gmail') {
50
50
  refreshedAccount = await this.gmailService.refreshTokenIfNeeded(this.supabase, account);
51
51
  }
52
+ else if (account.provider === 'outlook') {
53
+ refreshedAccount = await this.microsoftService.refreshTokenIfNeeded(this.supabase, account);
54
+ }
52
55
  // Update status to syncing
53
56
  await this.supabase
54
57
  .from('email_accounts')
@@ -73,16 +76,22 @@ export class EmailProcessorService {
73
76
  if (eventLogger)
74
77
  await eventLogger.info('Running', 'Starting sync process');
75
78
  // Process based on provider
76
- if (refreshedAccount.provider === 'gmail') {
77
- await this.processGmailAccount(refreshedAccount, rules || [], settings, result, eventLogger);
79
+ try {
80
+ if (refreshedAccount.provider === 'gmail') {
81
+ await this.processGmailAccount(refreshedAccount, rules || [], settings, result, eventLogger);
82
+ }
83
+ else if (refreshedAccount.provider === 'outlook') {
84
+ await this.processOutlookAccount(refreshedAccount, rules || [], settings, result, eventLogger);
85
+ }
78
86
  }
79
- else if (refreshedAccount.provider === 'outlook') {
80
- await this.processOutlookAccount(refreshedAccount, rules || [], settings, result, eventLogger);
87
+ catch (providerError) {
88
+ const providerName = refreshedAccount.provider === 'gmail' ? 'Gmail' : 'Outlook';
89
+ throw new Error(`${providerName} Sync Error: ${providerError instanceof Error ? providerError.message : String(providerError)}`);
81
90
  }
82
91
  // After processing new emails, run retention rules for this account
83
92
  await this.runRetentionRules(refreshedAccount, rules || [], settings, result, eventLogger);
84
93
  // Wait for background worker to process the queue (ensure sync is fully complete before event)
85
- await this.processQueue(userId, settings).catch(err => logger.error('Background worker failed', err));
94
+ await this.processQueue(userId, settings, result).catch(err => logger.error('Background worker failed', err));
86
95
  // Update log and account on success
87
96
  if (log) {
88
97
  if (eventLogger) {
@@ -399,7 +408,7 @@ export class EmailProcessorService {
399
408
  /**
400
409
  * Background Worker: Processes pending emails for a user recursively until empty.
401
410
  */
402
- async processQueue(userId, settings) {
411
+ async processQueue(userId, settings, result) {
403
412
  logger.info('Worker: Checking queue', { userId });
404
413
  // Fetch up to 5 pending emails for this user
405
414
  const { data: pendingEmails, error } = await this.supabase
@@ -418,13 +427,13 @@ export class EmailProcessorService {
418
427
  }
419
428
  logger.info('Worker: Processing batch', { userId, count: pendingEmails.length });
420
429
  for (const email of pendingEmails) {
421
- await this.processPendingEmail(email, userId, settings);
430
+ await this.processPendingEmail(email, userId, settings, result);
422
431
  }
423
432
  // Slight delay to prevent hitting rate limits too fast, then check again
424
433
  await new Promise(resolve => setTimeout(resolve, 1000));
425
- return this.processQueue(userId, settings);
434
+ return this.processQueue(userId, settings, result);
426
435
  }
427
- async processPendingEmail(email, userId, settings) {
436
+ async processPendingEmail(email, userId, settings, result) {
428
437
  // Create a real processing log entry for this background task to ensure RLS compliance
429
438
  const { data: log } = await this.supabase
430
439
  .from('processing_logs')
@@ -575,6 +584,13 @@ export class EmailProcessorService {
575
584
  // Use AI-generated draft content if available (handle null from AI)
576
585
  const draftContent = action === 'draft' ? (analysis.draft_content || undefined) : undefined;
577
586
  await this.executeAction(account, email, action, draftContent, eventLogger, `Rule: ${matchedRule?.name || analysis.matched_rule.rule_name}`, matchedRule?.attachments);
587
+ // Update metrics if result object provided
588
+ if (result) {
589
+ if (action === 'delete')
590
+ result.deleted++;
591
+ else if (action === 'draft')
592
+ result.drafted++;
593
+ }
578
594
  }
579
595
  }
580
596
  else if (eventLogger && rules && rules.length > 0) {