@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.
- package/api/src/services/microsoft.ts +67 -7
- package/api/src/services/processor.ts +22 -9
- package/dist/api/src/services/microsoft.js +63 -7
- package/dist/api/src/services/processor.js +25 -9
- package/dist/assets/{index-CWxEsWKX.js → index-B8sWXpKN.js} +3 -3
- package/dist/index.html +1 -1
- package/package.json +1 -1
|
@@ -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:
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
//
|
|
142
|
-
//
|
|
143
|
-
throw new Error('
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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,
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
//
|
|
100
|
-
//
|
|
101
|
-
throw new Error('
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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) {
|