@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.
Files changed (74) hide show
  1. package/api/server.ts +4 -8
  2. package/api/src/config/index.ts +6 -3
  3. package/bin/email-automator-setup.js +2 -3
  4. package/bin/email-automator.js +7 -11
  5. package/dist/api/server.js +109 -0
  6. package/dist/api/src/config/index.js +88 -0
  7. package/dist/api/src/middleware/auth.js +119 -0
  8. package/dist/api/src/middleware/errorHandler.js +78 -0
  9. package/dist/api/src/middleware/index.js +4 -0
  10. package/dist/api/src/middleware/rateLimit.js +57 -0
  11. package/dist/api/src/middleware/validation.js +111 -0
  12. package/dist/api/src/routes/actions.js +173 -0
  13. package/dist/api/src/routes/auth.js +106 -0
  14. package/dist/api/src/routes/emails.js +100 -0
  15. package/dist/api/src/routes/health.js +33 -0
  16. package/dist/api/src/routes/index.js +19 -0
  17. package/dist/api/src/routes/migrate.js +61 -0
  18. package/dist/api/src/routes/rules.js +104 -0
  19. package/dist/api/src/routes/settings.js +178 -0
  20. package/dist/api/src/routes/sync.js +118 -0
  21. package/dist/api/src/services/eventLogger.js +41 -0
  22. package/dist/api/src/services/gmail.js +350 -0
  23. package/dist/api/src/services/intelligence.js +243 -0
  24. package/dist/api/src/services/microsoft.js +256 -0
  25. package/dist/api/src/services/processor.js +503 -0
  26. package/dist/api/src/services/scheduler.js +210 -0
  27. package/dist/api/src/services/supabase.js +59 -0
  28. package/dist/api/src/utils/contentCleaner.js +94 -0
  29. package/dist/api/src/utils/crypto.js +68 -0
  30. package/dist/api/src/utils/logger.js +119 -0
  31. package/package.json +5 -5
  32. package/src/App.tsx +0 -622
  33. package/src/components/AccountSettings.tsx +0 -310
  34. package/src/components/AccountSettingsPage.tsx +0 -390
  35. package/src/components/Configuration.tsx +0 -1345
  36. package/src/components/Dashboard.tsx +0 -940
  37. package/src/components/ErrorBoundary.tsx +0 -71
  38. package/src/components/LiveTerminal.tsx +0 -308
  39. package/src/components/LoadingSpinner.tsx +0 -39
  40. package/src/components/Login.tsx +0 -371
  41. package/src/components/Logo.tsx +0 -57
  42. package/src/components/SetupWizard.tsx +0 -388
  43. package/src/components/Toast.tsx +0 -109
  44. package/src/components/migration/MigrationBanner.tsx +0 -97
  45. package/src/components/migration/MigrationModal.tsx +0 -458
  46. package/src/components/migration/MigrationPulseIndicator.tsx +0 -38
  47. package/src/components/mode-toggle.tsx +0 -24
  48. package/src/components/theme-provider.tsx +0 -72
  49. package/src/components/ui/alert.tsx +0 -66
  50. package/src/components/ui/button.tsx +0 -57
  51. package/src/components/ui/card.tsx +0 -75
  52. package/src/components/ui/dialog.tsx +0 -133
  53. package/src/components/ui/input.tsx +0 -22
  54. package/src/components/ui/label.tsx +0 -24
  55. package/src/components/ui/otp-input.tsx +0 -184
  56. package/src/context/AppContext.tsx +0 -422
  57. package/src/context/MigrationContext.tsx +0 -53
  58. package/src/context/TerminalContext.tsx +0 -31
  59. package/src/core/actions.ts +0 -76
  60. package/src/core/auth.ts +0 -108
  61. package/src/core/intelligence.ts +0 -76
  62. package/src/core/processor.ts +0 -112
  63. package/src/hooks/useRealtimeEmails.ts +0 -111
  64. package/src/index.css +0 -140
  65. package/src/lib/api-config.ts +0 -42
  66. package/src/lib/api-old.ts +0 -228
  67. package/src/lib/api.ts +0 -421
  68. package/src/lib/migration-check.ts +0 -264
  69. package/src/lib/sounds.ts +0 -120
  70. package/src/lib/supabase-config.ts +0 -117
  71. package/src/lib/supabase.ts +0 -28
  72. package/src/lib/types.ts +0 -166
  73. package/src/lib/utils.ts +0 -6
  74. 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
+ }