@realtimex/email-automator 2.2.0 → 2.3.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.
- package/api/server.ts +4 -8
- package/api/src/config/index.ts +6 -3
- package/bin/email-automator-setup.js +2 -3
- package/bin/email-automator.js +7 -11
- package/dist/api/server.js +109 -0
- package/dist/api/src/config/index.js +88 -0
- package/dist/api/src/middleware/auth.js +119 -0
- package/dist/api/src/middleware/errorHandler.js +78 -0
- package/dist/api/src/middleware/index.js +4 -0
- package/dist/api/src/middleware/rateLimit.js +57 -0
- package/dist/api/src/middleware/validation.js +111 -0
- package/dist/api/src/routes/actions.js +173 -0
- package/dist/api/src/routes/auth.js +106 -0
- package/dist/api/src/routes/emails.js +100 -0
- package/dist/api/src/routes/health.js +33 -0
- package/dist/api/src/routes/index.js +19 -0
- package/dist/api/src/routes/migrate.js +61 -0
- package/dist/api/src/routes/rules.js +104 -0
- package/dist/api/src/routes/settings.js +178 -0
- package/dist/api/src/routes/sync.js +118 -0
- package/dist/api/src/services/eventLogger.js +41 -0
- package/dist/api/src/services/gmail.js +350 -0
- package/dist/api/src/services/intelligence.js +243 -0
- package/dist/api/src/services/microsoft.js +256 -0
- package/dist/api/src/services/processor.js +503 -0
- package/dist/api/src/services/scheduler.js +210 -0
- package/dist/api/src/services/supabase.js +59 -0
- package/dist/api/src/utils/contentCleaner.js +94 -0
- package/dist/api/src/utils/crypto.js +68 -0
- package/dist/api/src/utils/logger.js +119 -0
- package/package.json +5 -5
- package/src/App.tsx +0 -622
- package/src/components/AccountSettings.tsx +0 -310
- package/src/components/AccountSettingsPage.tsx +0 -390
- package/src/components/Configuration.tsx +0 -1345
- package/src/components/Dashboard.tsx +0 -940
- package/src/components/ErrorBoundary.tsx +0 -71
- package/src/components/LiveTerminal.tsx +0 -308
- package/src/components/LoadingSpinner.tsx +0 -39
- package/src/components/Login.tsx +0 -371
- package/src/components/Logo.tsx +0 -57
- package/src/components/SetupWizard.tsx +0 -388
- package/src/components/Toast.tsx +0 -109
- package/src/components/migration/MigrationBanner.tsx +0 -97
- package/src/components/migration/MigrationModal.tsx +0 -458
- package/src/components/migration/MigrationPulseIndicator.tsx +0 -38
- package/src/components/mode-toggle.tsx +0 -24
- package/src/components/theme-provider.tsx +0 -72
- package/src/components/ui/alert.tsx +0 -66
- package/src/components/ui/button.tsx +0 -57
- package/src/components/ui/card.tsx +0 -75
- package/src/components/ui/dialog.tsx +0 -133
- package/src/components/ui/input.tsx +0 -22
- package/src/components/ui/label.tsx +0 -24
- package/src/components/ui/otp-input.tsx +0 -184
- package/src/context/AppContext.tsx +0 -422
- package/src/context/MigrationContext.tsx +0 -53
- package/src/context/TerminalContext.tsx +0 -31
- package/src/core/actions.ts +0 -76
- package/src/core/auth.ts +0 -108
- package/src/core/intelligence.ts +0 -76
- package/src/core/processor.ts +0 -112
- package/src/hooks/useRealtimeEmails.ts +0 -111
- package/src/index.css +0 -140
- package/src/lib/api-config.ts +0 -42
- package/src/lib/api-old.ts +0 -228
- package/src/lib/api.ts +0 -421
- package/src/lib/migration-check.ts +0 -264
- package/src/lib/sounds.ts +0 -120
- package/src/lib/supabase-config.ts +0 -117
- package/src/lib/supabase.ts +0 -28
- package/src/lib/types.ts +0 -166
- package/src/lib/utils.ts +0 -6
- package/src/main.tsx +0 -10
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { asyncHandler } from '../middleware/errorHandler.js';
|
|
3
|
+
import { authMiddleware } from '../middleware/auth.js';
|
|
4
|
+
import { apiRateLimit } from '../middleware/rateLimit.js';
|
|
5
|
+
import { validateBody, schemas } from '../middleware/validation.js';
|
|
6
|
+
import { createLogger } from '../utils/logger.js';
|
|
7
|
+
const router = Router();
|
|
8
|
+
const logger = createLogger('SettingsRoutes');
|
|
9
|
+
// Get user settings
|
|
10
|
+
router.get('/', authMiddleware, asyncHandler(async (req, res) => {
|
|
11
|
+
// Fetch settings and integrations in parallel
|
|
12
|
+
const [settingsResult, integrationsResult] = await Promise.all([
|
|
13
|
+
req.supabase
|
|
14
|
+
.from('user_settings')
|
|
15
|
+
.select('*')
|
|
16
|
+
.eq('user_id', req.user.id)
|
|
17
|
+
.single(),
|
|
18
|
+
req.supabase
|
|
19
|
+
.from('integrations')
|
|
20
|
+
.select('*')
|
|
21
|
+
.eq('user_id', req.user.id)
|
|
22
|
+
]);
|
|
23
|
+
const settingsData = settingsResult.data;
|
|
24
|
+
const integrationsData = integrationsResult.data || [];
|
|
25
|
+
// Return defaults if no settings exist
|
|
26
|
+
const settings = settingsData || {
|
|
27
|
+
llm_model: null,
|
|
28
|
+
llm_base_url: null,
|
|
29
|
+
sync_interval_minutes: 5,
|
|
30
|
+
};
|
|
31
|
+
// Merge integration credentials back into settings for frontend compatibility
|
|
32
|
+
const googleIntegration = integrationsData.find((i) => i.provider === 'google');
|
|
33
|
+
if (googleIntegration?.credentials) {
|
|
34
|
+
settings.google_client_id = googleIntegration.credentials.client_id;
|
|
35
|
+
settings.google_client_secret = googleIntegration.credentials.client_secret;
|
|
36
|
+
}
|
|
37
|
+
const microsoftIntegration = integrationsData.find((i) => i.provider === 'microsoft');
|
|
38
|
+
if (microsoftIntegration?.credentials) {
|
|
39
|
+
settings.microsoft_client_id = microsoftIntegration.credentials.client_id;
|
|
40
|
+
settings.microsoft_client_secret = microsoftIntegration.credentials.client_secret;
|
|
41
|
+
settings.microsoft_tenant_id = microsoftIntegration.credentials.tenant_id;
|
|
42
|
+
}
|
|
43
|
+
res.json({ settings });
|
|
44
|
+
}));
|
|
45
|
+
// Update user settings
|
|
46
|
+
router.patch('/', apiRateLimit, authMiddleware, validateBody(schemas.updateSettings), asyncHandler(async (req, res) => {
|
|
47
|
+
const { google_client_id, google_client_secret, microsoft_client_id, microsoft_client_secret, microsoft_tenant_id, ...userSettingsUpdates } = req.body;
|
|
48
|
+
const userId = req.user.id;
|
|
49
|
+
// 1. Update user_settings
|
|
50
|
+
const { data: updatedSettings, error: settingsError } = await req.supabase
|
|
51
|
+
.from('user_settings')
|
|
52
|
+
.upsert({
|
|
53
|
+
user_id: userId,
|
|
54
|
+
...userSettingsUpdates,
|
|
55
|
+
updated_at: new Date().toISOString(),
|
|
56
|
+
}, { onConflict: 'user_id' })
|
|
57
|
+
.select()
|
|
58
|
+
.single();
|
|
59
|
+
if (settingsError)
|
|
60
|
+
throw settingsError;
|
|
61
|
+
// 2. Handle Google Integration
|
|
62
|
+
if (google_client_id || google_client_secret) {
|
|
63
|
+
const { data: existing } = await req.supabase
|
|
64
|
+
.from('integrations')
|
|
65
|
+
.select('credentials')
|
|
66
|
+
.eq('user_id', userId)
|
|
67
|
+
.eq('provider', 'google')
|
|
68
|
+
.single();
|
|
69
|
+
const credentials = {};
|
|
70
|
+
if (google_client_id)
|
|
71
|
+
credentials.client_id = google_client_id;
|
|
72
|
+
if (google_client_secret)
|
|
73
|
+
credentials.client_secret = google_client_secret;
|
|
74
|
+
const newCredentials = { ...(existing?.credentials || {}), ...credentials };
|
|
75
|
+
await req.supabase
|
|
76
|
+
.from('integrations')
|
|
77
|
+
.upsert({
|
|
78
|
+
user_id: userId,
|
|
79
|
+
provider: 'google',
|
|
80
|
+
credentials: newCredentials,
|
|
81
|
+
updated_at: new Date().toISOString()
|
|
82
|
+
}, { onConflict: 'user_id, provider' });
|
|
83
|
+
}
|
|
84
|
+
// 3. Handle Microsoft Integration
|
|
85
|
+
if (microsoft_client_id || microsoft_client_secret || microsoft_tenant_id) {
|
|
86
|
+
const { data: existing } = await req.supabase
|
|
87
|
+
.from('integrations')
|
|
88
|
+
.select('credentials')
|
|
89
|
+
.eq('user_id', userId)
|
|
90
|
+
.eq('provider', 'microsoft')
|
|
91
|
+
.single();
|
|
92
|
+
const credentials = {};
|
|
93
|
+
if (microsoft_client_id)
|
|
94
|
+
credentials.client_id = microsoft_client_id;
|
|
95
|
+
if (microsoft_client_secret)
|
|
96
|
+
credentials.client_secret = microsoft_client_secret;
|
|
97
|
+
if (microsoft_tenant_id)
|
|
98
|
+
credentials.tenant_id = microsoft_tenant_id;
|
|
99
|
+
const newCredentials = { ...(existing?.credentials || {}), ...credentials };
|
|
100
|
+
await req.supabase
|
|
101
|
+
.from('integrations')
|
|
102
|
+
.upsert({
|
|
103
|
+
user_id: userId,
|
|
104
|
+
provider: 'microsoft',
|
|
105
|
+
credentials: newCredentials,
|
|
106
|
+
updated_at: new Date().toISOString()
|
|
107
|
+
}, { onConflict: 'user_id, provider' });
|
|
108
|
+
}
|
|
109
|
+
// Construct response with merged values
|
|
110
|
+
const finalSettings = {
|
|
111
|
+
...updatedSettings,
|
|
112
|
+
// Re-inject the values from request if they were present
|
|
113
|
+
...(google_client_id ? { google_client_id } : {}),
|
|
114
|
+
...(google_client_secret ? { google_client_secret } : {}),
|
|
115
|
+
...(microsoft_client_id ? { microsoft_client_id } : {}),
|
|
116
|
+
...(microsoft_client_secret ? { microsoft_client_secret } : {}),
|
|
117
|
+
...(microsoft_tenant_id ? { microsoft_tenant_id } : {}),
|
|
118
|
+
};
|
|
119
|
+
logger.info('Settings updated', { userId });
|
|
120
|
+
res.json({ settings: finalSettings });
|
|
121
|
+
}));
|
|
122
|
+
// Test LLM Connection
|
|
123
|
+
router.post('/test-llm', apiRateLimit, authMiddleware, asyncHandler(async (req, res) => {
|
|
124
|
+
const { llm_model, llm_base_url, llm_api_key } = req.body;
|
|
125
|
+
const { getIntelligenceService } = await import('../services/intelligence.js');
|
|
126
|
+
const intelligence = getIntelligenceService({
|
|
127
|
+
model: llm_model,
|
|
128
|
+
baseUrl: llm_base_url,
|
|
129
|
+
apiKey: llm_api_key,
|
|
130
|
+
});
|
|
131
|
+
const result = await intelligence.testConnection();
|
|
132
|
+
res.json(result);
|
|
133
|
+
}));
|
|
134
|
+
// Get analytics/stats
|
|
135
|
+
router.get('/stats', authMiddleware, asyncHandler(async (req, res) => {
|
|
136
|
+
const userId = req.user.id;
|
|
137
|
+
// Get email counts by category
|
|
138
|
+
const { data: emailStats } = await req.supabase
|
|
139
|
+
.from('emails')
|
|
140
|
+
.select('category, is_useless, action_taken')
|
|
141
|
+
.eq('email_accounts.user_id', userId);
|
|
142
|
+
// Get account counts
|
|
143
|
+
const { data: accounts } = await req.supabase
|
|
144
|
+
.from('email_accounts')
|
|
145
|
+
.select('id, provider')
|
|
146
|
+
.eq('user_id', userId);
|
|
147
|
+
// Get recent processing logs
|
|
148
|
+
const { data: recentLogs } = await req.supabase
|
|
149
|
+
.from('processing_logs')
|
|
150
|
+
.select('*')
|
|
151
|
+
.eq('user_id', userId)
|
|
152
|
+
.order('started_at', { ascending: false })
|
|
153
|
+
.limit(5);
|
|
154
|
+
// Calculate stats
|
|
155
|
+
const stats = {
|
|
156
|
+
totalEmails: emailStats?.length || 0,
|
|
157
|
+
categoryCounts: {},
|
|
158
|
+
actionCounts: {},
|
|
159
|
+
uselessCount: emailStats?.filter(e => e.is_useless).length || 0,
|
|
160
|
+
accountCount: accounts?.length || 0,
|
|
161
|
+
accountsByProvider: {},
|
|
162
|
+
recentSyncs: recentLogs || [],
|
|
163
|
+
};
|
|
164
|
+
// Count by category
|
|
165
|
+
for (const email of emailStats || []) {
|
|
166
|
+
const cat = email.category || 'uncategorized';
|
|
167
|
+
stats.categoryCounts[cat] = (stats.categoryCounts[cat] || 0) + 1;
|
|
168
|
+
const action = email.action_taken || 'none';
|
|
169
|
+
stats.actionCounts[action] = (stats.actionCounts[action] || 0) + 1;
|
|
170
|
+
}
|
|
171
|
+
// Count by provider
|
|
172
|
+
for (const account of accounts || []) {
|
|
173
|
+
stats.accountsByProvider[account.provider] =
|
|
174
|
+
(stats.accountsByProvider[account.provider] || 0) + 1;
|
|
175
|
+
}
|
|
176
|
+
res.json({ stats });
|
|
177
|
+
}));
|
|
178
|
+
export default router;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { asyncHandler } from '../middleware/errorHandler.js';
|
|
3
|
+
import { authMiddleware } from '../middleware/auth.js';
|
|
4
|
+
import { syncRateLimit } from '../middleware/rateLimit.js';
|
|
5
|
+
import { validateBody, schemas } from '../middleware/validation.js';
|
|
6
|
+
import { EmailProcessorService } from '../services/processor.js';
|
|
7
|
+
import { createLogger } from '../utils/logger.js';
|
|
8
|
+
const router = Router();
|
|
9
|
+
const logger = createLogger('SyncRoutes');
|
|
10
|
+
// Trigger sync for an account
|
|
11
|
+
router.post('/', syncRateLimit, authMiddleware, validateBody(schemas.syncRequest), asyncHandler(async (req, res) => {
|
|
12
|
+
const { accountId } = req.body;
|
|
13
|
+
const userId = req.user.id;
|
|
14
|
+
if (!req.supabase) {
|
|
15
|
+
return res.status(503).json({
|
|
16
|
+
error: 'Supabase service is not configured. Please set your SUPABASE_URL and SUPABASE_ANON_KEY in the .env file and restart the server.'
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
// Verify account ownership
|
|
20
|
+
const { data: account, error } = await req.supabase
|
|
21
|
+
.from('email_accounts')
|
|
22
|
+
.select('id')
|
|
23
|
+
.eq('id', accountId)
|
|
24
|
+
.eq('user_id', userId)
|
|
25
|
+
.single();
|
|
26
|
+
if (error || !account) {
|
|
27
|
+
return res.status(404).json({ error: 'Account not found' });
|
|
28
|
+
}
|
|
29
|
+
// Run sync and wait for result
|
|
30
|
+
const processor = new EmailProcessorService(req.supabase);
|
|
31
|
+
try {
|
|
32
|
+
const result = await processor.syncAccount(accountId, userId);
|
|
33
|
+
logger.info('Sync completed', { accountId, ...result });
|
|
34
|
+
res.json({
|
|
35
|
+
message: 'Sync completed',
|
|
36
|
+
accountId,
|
|
37
|
+
...result,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
logger.error('Sync failed', err, { accountId });
|
|
42
|
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
43
|
+
res.status(500).json({
|
|
44
|
+
error: errorMessage,
|
|
45
|
+
accountId,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}));
|
|
49
|
+
// Sync all accounts for user
|
|
50
|
+
router.post('/all', syncRateLimit, authMiddleware, asyncHandler(async (req, res) => {
|
|
51
|
+
const userId = req.user.id;
|
|
52
|
+
if (!req.supabase) {
|
|
53
|
+
return res.status(503).json({
|
|
54
|
+
error: 'Supabase service is not configured. Please set your SUPABASE_URL and SUPABASE_ANON_KEY in the .env file and restart the server.'
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
const { data: accounts, error } = await req.supabase
|
|
58
|
+
.from('email_accounts')
|
|
59
|
+
.select('id')
|
|
60
|
+
.eq('user_id', userId)
|
|
61
|
+
.eq('is_active', true);
|
|
62
|
+
if (error)
|
|
63
|
+
throw error;
|
|
64
|
+
if (!accounts || accounts.length === 0) {
|
|
65
|
+
return res.status(400).json({ error: 'No connected accounts' });
|
|
66
|
+
}
|
|
67
|
+
const processor = new EmailProcessorService(req.supabase);
|
|
68
|
+
// Sync all accounts and collect results
|
|
69
|
+
const results = await Promise.allSettled(accounts.map(account => processor.syncAccount(account.id, userId)));
|
|
70
|
+
const summary = {
|
|
71
|
+
total: accounts.length,
|
|
72
|
+
success: 0,
|
|
73
|
+
failed: 0,
|
|
74
|
+
errors: [],
|
|
75
|
+
};
|
|
76
|
+
results.forEach((result, index) => {
|
|
77
|
+
if (result.status === 'fulfilled') {
|
|
78
|
+
summary.success++;
|
|
79
|
+
logger.info('Sync completed', { accountId: accounts[index].id, ...result.value });
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
summary.failed++;
|
|
83
|
+
const errorMessage = result.reason instanceof Error ? result.reason.message : 'Unknown error';
|
|
84
|
+
summary.errors.push({ accountId: accounts[index].id, error: errorMessage });
|
|
85
|
+
logger.error('Sync failed', result.reason, { accountId: accounts[index].id });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
// Return error status if all syncs failed
|
|
89
|
+
if (summary.failed === summary.total) {
|
|
90
|
+
return res.status(500).json({
|
|
91
|
+
message: 'All syncs failed',
|
|
92
|
+
...summary,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
res.json({
|
|
96
|
+
message: summary.failed > 0 ? 'Sync completed with errors' : 'Sync completed',
|
|
97
|
+
...summary,
|
|
98
|
+
});
|
|
99
|
+
}));
|
|
100
|
+
// Get sync history/logs
|
|
101
|
+
router.get('/logs', authMiddleware, asyncHandler(async (req, res) => {
|
|
102
|
+
const { limit = '10' } = req.query;
|
|
103
|
+
if (!req.supabase) {
|
|
104
|
+
return res.status(503).json({
|
|
105
|
+
error: 'Supabase service is not configured. Please set your SUPABASE_URL and SUPABASE_ANON_KEY in the .env file and restart the server.'
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const { data, error } = await req.supabase
|
|
109
|
+
.from('processing_logs')
|
|
110
|
+
.select('*')
|
|
111
|
+
.eq('user_id', req.user.id)
|
|
112
|
+
.order('started_at', { ascending: false })
|
|
113
|
+
.limit(parseInt(limit, 10));
|
|
114
|
+
if (error)
|
|
115
|
+
throw error;
|
|
116
|
+
res.json({ logs: data || [] });
|
|
117
|
+
}));
|
|
118
|
+
export default router;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createLogger } from '../utils/logger.js';
|
|
2
|
+
const logger = createLogger('EventLogger');
|
|
3
|
+
export class EventLogger {
|
|
4
|
+
supabase;
|
|
5
|
+
runId;
|
|
6
|
+
constructor(supabase, runId) {
|
|
7
|
+
this.supabase = supabase;
|
|
8
|
+
this.runId = runId;
|
|
9
|
+
}
|
|
10
|
+
async log(eventType, agentState, details, emailId) {
|
|
11
|
+
try {
|
|
12
|
+
const { error } = await this.supabase.from('processing_events').insert({
|
|
13
|
+
run_id: this.runId,
|
|
14
|
+
email_id: emailId || null,
|
|
15
|
+
event_type: eventType,
|
|
16
|
+
agent_state: agentState,
|
|
17
|
+
details: details || {},
|
|
18
|
+
created_at: new Date().toISOString()
|
|
19
|
+
});
|
|
20
|
+
if (error) {
|
|
21
|
+
console.error('[EventLogger] Supabase Insert Error:', error);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
// Non-blocking error logging - don't fail the job because logging failed
|
|
26
|
+
logger.error('Failed to write processing event', error);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async info(state, message, details, emailId) {
|
|
30
|
+
await this.log('info', state, { message, ...details }, emailId);
|
|
31
|
+
}
|
|
32
|
+
async analysis(state, emailId, analysis) {
|
|
33
|
+
await this.log('analysis', state, analysis, emailId);
|
|
34
|
+
}
|
|
35
|
+
async action(state, emailId, action, reason) {
|
|
36
|
+
await this.log('action', state, { action, reason }, emailId);
|
|
37
|
+
}
|
|
38
|
+
async error(state, error, emailId) {
|
|
39
|
+
await this.log('error', state, { error: error.message || error }, emailId);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { google } from 'googleapis';
|
|
2
|
+
import { config } from '../config/index.js';
|
|
3
|
+
import { createLogger } from '../utils/logger.js';
|
|
4
|
+
const logger = createLogger('GmailService');
|
|
5
|
+
export class GmailService {
|
|
6
|
+
createOAuth2Client(credentials) {
|
|
7
|
+
return new google.auth.OAuth2(credentials?.clientId || config.gmail.clientId, credentials?.clientSecret || config.gmail.clientSecret, credentials?.redirectUri || config.gmail.redirectUri);
|
|
8
|
+
}
|
|
9
|
+
async getProviderCredentials(supabase, userId) {
|
|
10
|
+
const { data: integration } = await supabase
|
|
11
|
+
.from('integrations')
|
|
12
|
+
.select('credentials')
|
|
13
|
+
.eq('user_id', userId)
|
|
14
|
+
.eq('provider', 'google')
|
|
15
|
+
.single();
|
|
16
|
+
const creds = integration?.credentials;
|
|
17
|
+
if (creds?.client_id && creds?.client_secret) {
|
|
18
|
+
return {
|
|
19
|
+
clientId: creds.client_id,
|
|
20
|
+
clientSecret: creds.client_secret,
|
|
21
|
+
redirectUri: config.gmail.redirectUri
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
if (config.gmail.clientId && config.gmail.clientSecret) {
|
|
25
|
+
return {
|
|
26
|
+
clientId: config.gmail.clientId,
|
|
27
|
+
clientSecret: config.gmail.clientSecret,
|
|
28
|
+
redirectUri: config.gmail.redirectUri
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
throw new Error('Gmail OAuth credentials not configured (Database or Env)');
|
|
32
|
+
}
|
|
33
|
+
getAuthUrl(scopes = ['https://www.googleapis.com/auth/gmail.modify']) {
|
|
34
|
+
const client = this.createOAuth2Client();
|
|
35
|
+
return client.generateAuthUrl({
|
|
36
|
+
access_type: 'offline',
|
|
37
|
+
scope: scopes,
|
|
38
|
+
prompt: 'consent',
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
async exchangeCode(code) {
|
|
42
|
+
const client = this.createOAuth2Client();
|
|
43
|
+
const { tokens } = await client.getToken(code);
|
|
44
|
+
return {
|
|
45
|
+
access_token: tokens.access_token,
|
|
46
|
+
refresh_token: tokens.refresh_token ?? undefined,
|
|
47
|
+
expiry_date: tokens.expiry_date ?? undefined,
|
|
48
|
+
scope: tokens.scope ?? undefined,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
async saveAccount(supabase, userId, emailAddress, tokens) {
|
|
52
|
+
const { data, error } = await supabase
|
|
53
|
+
.from('email_accounts')
|
|
54
|
+
.upsert({
|
|
55
|
+
user_id: userId,
|
|
56
|
+
email_address: emailAddress,
|
|
57
|
+
provider: 'gmail',
|
|
58
|
+
access_token: tokens.access_token,
|
|
59
|
+
refresh_token: tokens.refresh_token || null,
|
|
60
|
+
token_expires_at: tokens.expiry_date ? new Date(tokens.expiry_date).toISOString() : null,
|
|
61
|
+
scopes: tokens.scope?.split(' ') || [],
|
|
62
|
+
is_active: true,
|
|
63
|
+
updated_at: new Date().toISOString(),
|
|
64
|
+
}, { onConflict: 'user_id, email_address' })
|
|
65
|
+
.select()
|
|
66
|
+
.single();
|
|
67
|
+
if (error)
|
|
68
|
+
throw error;
|
|
69
|
+
return data;
|
|
70
|
+
}
|
|
71
|
+
async getAuthenticatedClient(account) {
|
|
72
|
+
const accessToken = account.access_token || '';
|
|
73
|
+
const refreshToken = account.refresh_token || '';
|
|
74
|
+
const client = this.createOAuth2Client();
|
|
75
|
+
client.setCredentials({
|
|
76
|
+
access_token: accessToken,
|
|
77
|
+
refresh_token: refreshToken,
|
|
78
|
+
expiry_date: account.token_expires_at ? new Date(account.token_expires_at).getTime() : undefined,
|
|
79
|
+
});
|
|
80
|
+
return google.gmail({ version: 'v1', auth: client });
|
|
81
|
+
}
|
|
82
|
+
async refreshTokenIfNeeded(supabase, account) {
|
|
83
|
+
if (!account.token_expires_at)
|
|
84
|
+
return account;
|
|
85
|
+
const expiresAt = new Date(account.token_expires_at).getTime();
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
const bufferMs = 5 * 60 * 1000; // 5 minutes buffer
|
|
88
|
+
if (expiresAt > now + bufferMs) {
|
|
89
|
+
return account; // Token still valid
|
|
90
|
+
}
|
|
91
|
+
logger.info('Refreshing Gmail token', { accountId: account.id });
|
|
92
|
+
const refreshToken = account.refresh_token;
|
|
93
|
+
if (!refreshToken) {
|
|
94
|
+
throw new Error('No refresh token available');
|
|
95
|
+
}
|
|
96
|
+
const credentials = await this.getProviderCredentials(supabase, account.user_id);
|
|
97
|
+
const client = this.createOAuth2Client(credentials);
|
|
98
|
+
client.setCredentials({ refresh_token: refreshToken });
|
|
99
|
+
const { credentials: newTokens } = await client.refreshAccessToken();
|
|
100
|
+
const { data, error } = await supabase
|
|
101
|
+
.from('email_accounts')
|
|
102
|
+
.update({
|
|
103
|
+
access_token: newTokens.access_token,
|
|
104
|
+
token_expires_at: newTokens.expiry_date
|
|
105
|
+
? new Date(newTokens.expiry_date).toISOString()
|
|
106
|
+
: null,
|
|
107
|
+
updated_at: new Date().toISOString(),
|
|
108
|
+
})
|
|
109
|
+
.eq('id', account.id)
|
|
110
|
+
.select()
|
|
111
|
+
.single();
|
|
112
|
+
if (error)
|
|
113
|
+
throw error;
|
|
114
|
+
return data;
|
|
115
|
+
}
|
|
116
|
+
async fetchMessages(account, options = {}) {
|
|
117
|
+
const gmail = await this.getAuthenticatedClient(account);
|
|
118
|
+
const { maxResults = config.processing.batchSize, query, pageToken } = options;
|
|
119
|
+
const response = await gmail.users.messages.list({
|
|
120
|
+
userId: 'me',
|
|
121
|
+
maxResults,
|
|
122
|
+
q: query,
|
|
123
|
+
pageToken,
|
|
124
|
+
});
|
|
125
|
+
const messages = [];
|
|
126
|
+
for (const msg of response.data.messages || []) {
|
|
127
|
+
if (!msg.id)
|
|
128
|
+
continue;
|
|
129
|
+
try {
|
|
130
|
+
const detail = await gmail.users.messages.get({
|
|
131
|
+
userId: 'me',
|
|
132
|
+
id: msg.id,
|
|
133
|
+
format: 'full',
|
|
134
|
+
});
|
|
135
|
+
const parsed = this.parseMessage(detail.data);
|
|
136
|
+
if (parsed) {
|
|
137
|
+
messages.push(parsed);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
logger.warn('Failed to fetch message details', { messageId: msg.id, error });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
messages,
|
|
146
|
+
nextPageToken: response.data.nextPageToken ?? undefined,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
parseMessage(message) {
|
|
150
|
+
if (!message.id || !message.threadId)
|
|
151
|
+
return null;
|
|
152
|
+
const headers = message.payload?.headers || [];
|
|
153
|
+
const getHeader = (name) => headers.find(h => h.name?.toLowerCase() === name.toLowerCase())?.value || '';
|
|
154
|
+
let body = '';
|
|
155
|
+
const payload = message.payload;
|
|
156
|
+
if (payload?.parts) {
|
|
157
|
+
// Multipart message
|
|
158
|
+
const textPart = payload.parts.find(p => p.mimeType === 'text/plain');
|
|
159
|
+
const htmlPart = payload.parts.find(p => p.mimeType === 'text/html');
|
|
160
|
+
const part = textPart || htmlPart || payload.parts[0];
|
|
161
|
+
body = this.decodeBody(part?.body?.data);
|
|
162
|
+
}
|
|
163
|
+
else if (payload?.body?.data) {
|
|
164
|
+
body = this.decodeBody(payload.body.data);
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
id: message.id,
|
|
168
|
+
threadId: message.threadId,
|
|
169
|
+
subject: getHeader('Subject') || 'No Subject',
|
|
170
|
+
sender: getHeader('From'),
|
|
171
|
+
recipient: getHeader('To'),
|
|
172
|
+
date: getHeader('Date'),
|
|
173
|
+
body,
|
|
174
|
+
snippet: message.snippet || '',
|
|
175
|
+
headers: {
|
|
176
|
+
importance: getHeader('Importance') || getHeader('X-Priority'),
|
|
177
|
+
listUnsubscribe: getHeader('List-Unsubscribe'),
|
|
178
|
+
autoSubmitted: getHeader('Auto-Submitted'),
|
|
179
|
+
mailer: getHeader('X-Mailer'),
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
decodeBody(data) {
|
|
184
|
+
if (!data)
|
|
185
|
+
return '';
|
|
186
|
+
try {
|
|
187
|
+
return Buffer.from(data, 'base64').toString('utf-8');
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return '';
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
async trashMessage(account, messageId) {
|
|
194
|
+
const gmail = await this.getAuthenticatedClient(account);
|
|
195
|
+
await gmail.users.messages.trash({ userId: 'me', id: messageId });
|
|
196
|
+
logger.debug('Message trashed', { messageId });
|
|
197
|
+
}
|
|
198
|
+
async archiveMessage(account, messageId) {
|
|
199
|
+
const gmail = await this.getAuthenticatedClient(account);
|
|
200
|
+
await gmail.users.messages.modify({
|
|
201
|
+
userId: 'me',
|
|
202
|
+
id: messageId,
|
|
203
|
+
requestBody: { removeLabelIds: ['INBOX'] },
|
|
204
|
+
});
|
|
205
|
+
logger.debug('Message archived', { messageId });
|
|
206
|
+
}
|
|
207
|
+
async createDraft(account, originalMessageId, replyContent, supabase, attachments) {
|
|
208
|
+
const gmail = await this.getAuthenticatedClient(account);
|
|
209
|
+
// Fetch original message to get threadId and Message-ID for threading
|
|
210
|
+
const original = await gmail.users.messages.get({ userId: 'me', id: originalMessageId });
|
|
211
|
+
const headers = original.data.payload?.headers || [];
|
|
212
|
+
const getHeader = (name) => headers.find(h => h.name?.toLowerCase() === name.toLowerCase())?.value || '';
|
|
213
|
+
const toAddress = getHeader('From');
|
|
214
|
+
const originalSubject = getHeader('Subject');
|
|
215
|
+
const originalMsgId = getHeader('Message-ID');
|
|
216
|
+
const threadId = original.data.threadId;
|
|
217
|
+
// Ensure subject has Re: prefix
|
|
218
|
+
const subject = originalSubject.toLowerCase().startsWith('re:')
|
|
219
|
+
? originalSubject
|
|
220
|
+
: `Re: ${originalSubject}`;
|
|
221
|
+
logger.info('Creating draft', { threadId, toAddress, subject });
|
|
222
|
+
// Threading headers: In-Reply-To should be the Message-ID of the mail we reply to
|
|
223
|
+
const replyHeaders = [];
|
|
224
|
+
if (originalMsgId) {
|
|
225
|
+
replyHeaders.push(`In-Reply-To: ${originalMsgId}`);
|
|
226
|
+
replyHeaders.push(`References: ${originalMsgId}`);
|
|
227
|
+
}
|
|
228
|
+
let rawMessage = '';
|
|
229
|
+
const boundary = `----=_Part_${Math.random().toString(36).substring(2)}`;
|
|
230
|
+
if (attachments && attachments.length > 0 && supabase) {
|
|
231
|
+
// Multipart message
|
|
232
|
+
rawMessage = [
|
|
233
|
+
`To: ${toAddress}`,
|
|
234
|
+
`Subject: ${subject}`,
|
|
235
|
+
...replyHeaders,
|
|
236
|
+
'MIME-Version: 1.0',
|
|
237
|
+
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
|
238
|
+
'',
|
|
239
|
+
`--${boundary}`,
|
|
240
|
+
'Content-Type: text/plain; charset="UTF-8"',
|
|
241
|
+
'Content-Transfer-Encoding: 7bit',
|
|
242
|
+
'',
|
|
243
|
+
replyContent,
|
|
244
|
+
'',
|
|
245
|
+
].join('\r\n');
|
|
246
|
+
for (const attachment of attachments) {
|
|
247
|
+
try {
|
|
248
|
+
const content = await this.fetchAttachment(supabase, attachment.path);
|
|
249
|
+
const base64Content = Buffer.from(content).toString('base64');
|
|
250
|
+
rawMessage += [
|
|
251
|
+
`--${boundary}`,
|
|
252
|
+
`Content-Type: ${attachment.type}; name="${attachment.name}"`,
|
|
253
|
+
`Content-Disposition: attachment; filename="${attachment.name}"`,
|
|
254
|
+
'Content-Transfer-Encoding: base64',
|
|
255
|
+
'',
|
|
256
|
+
base64Content,
|
|
257
|
+
'',
|
|
258
|
+
].join('\r\n');
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
logger.error('Failed to attach file', err, { path: attachment.path });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
rawMessage += `--${boundary}--`;
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
// Simple plain text message
|
|
268
|
+
rawMessage = [
|
|
269
|
+
`To: ${toAddress}`,
|
|
270
|
+
`Subject: ${subject}`,
|
|
271
|
+
...replyHeaders,
|
|
272
|
+
'MIME-Version: 1.0',
|
|
273
|
+
'Content-Type: text/plain; charset="UTF-8"',
|
|
274
|
+
'',
|
|
275
|
+
replyContent,
|
|
276
|
+
].join('\r\n');
|
|
277
|
+
}
|
|
278
|
+
const encodedMessage = Buffer.from(rawMessage)
|
|
279
|
+
.toString('base64')
|
|
280
|
+
.replace(/\+/g, '-')
|
|
281
|
+
.replace(/\//g, '_')
|
|
282
|
+
.replace(/=+$/, '');
|
|
283
|
+
try {
|
|
284
|
+
const draft = await gmail.users.drafts.create({
|
|
285
|
+
userId: 'me',
|
|
286
|
+
requestBody: {
|
|
287
|
+
message: {
|
|
288
|
+
threadId,
|
|
289
|
+
raw: encodedMessage,
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
const draftId = draft.data.id || 'unknown';
|
|
294
|
+
logger.info('Draft created successfully', { draftId, threadId });
|
|
295
|
+
return draftId;
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
logger.error('Gmail API Error creating draft', error);
|
|
299
|
+
throw error;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
async addLabel(account, messageId, labelIds) {
|
|
303
|
+
const gmail = await this.getAuthenticatedClient(account);
|
|
304
|
+
await gmail.users.messages.modify({
|
|
305
|
+
userId: 'me',
|
|
306
|
+
id: messageId,
|
|
307
|
+
requestBody: { addLabelIds: labelIds },
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
async removeLabel(account, messageId, labelIds) {
|
|
311
|
+
const gmail = await this.getAuthenticatedClient(account);
|
|
312
|
+
await gmail.users.messages.modify({
|
|
313
|
+
userId: 'me',
|
|
314
|
+
id: messageId,
|
|
315
|
+
requestBody: { removeLabelIds: labelIds },
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
async markAsRead(account, messageId) {
|
|
319
|
+
await this.removeLabel(account, messageId, ['UNREAD']);
|
|
320
|
+
logger.debug('Message marked as read', { messageId });
|
|
321
|
+
}
|
|
322
|
+
async starMessage(account, messageId) {
|
|
323
|
+
await this.addLabel(account, messageId, ['STARRED']);
|
|
324
|
+
logger.debug('Message starred', { messageId });
|
|
325
|
+
}
|
|
326
|
+
async fetchAttachment(supabase, path) {
|
|
327
|
+
const { data, error } = await supabase.storage
|
|
328
|
+
.from('rule-attachments')
|
|
329
|
+
.download(path);
|
|
330
|
+
if (error)
|
|
331
|
+
throw error;
|
|
332
|
+
return new Uint8Array(await data.arrayBuffer());
|
|
333
|
+
}
|
|
334
|
+
async getProfile(account) {
|
|
335
|
+
const gmail = await this.getAuthenticatedClient(account);
|
|
336
|
+
const profile = await gmail.users.getProfile({ userId: 'me' });
|
|
337
|
+
return {
|
|
338
|
+
emailAddress: profile.data.emailAddress || '',
|
|
339
|
+
messagesTotal: profile.data.messagesTotal || 0,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// Singleton
|
|
344
|
+
let instance = null;
|
|
345
|
+
export function getGmailService() {
|
|
346
|
+
if (!instance) {
|
|
347
|
+
instance = new GmailService();
|
|
348
|
+
}
|
|
349
|
+
return instance;
|
|
350
|
+
}
|