@realtimex/email-automator 2.1.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 (139) hide show
  1. package/.env.example +35 -0
  2. package/LICENSE +21 -0
  3. package/README.md +247 -0
  4. package/api/server.ts +130 -0
  5. package/api/src/config/index.ts +102 -0
  6. package/api/src/middleware/auth.ts +166 -0
  7. package/api/src/middleware/errorHandler.ts +97 -0
  8. package/api/src/middleware/index.ts +4 -0
  9. package/api/src/middleware/rateLimit.ts +87 -0
  10. package/api/src/middleware/validation.ts +118 -0
  11. package/api/src/routes/actions.ts +214 -0
  12. package/api/src/routes/auth.ts +157 -0
  13. package/api/src/routes/emails.ts +144 -0
  14. package/api/src/routes/health.ts +36 -0
  15. package/api/src/routes/index.ts +22 -0
  16. package/api/src/routes/migrate.ts +76 -0
  17. package/api/src/routes/rules.ts +149 -0
  18. package/api/src/routes/settings.ts +229 -0
  19. package/api/src/routes/sync.ts +152 -0
  20. package/api/src/services/eventLogger.ts +52 -0
  21. package/api/src/services/gmail.ts +456 -0
  22. package/api/src/services/intelligence.ts +288 -0
  23. package/api/src/services/microsoft.ts +368 -0
  24. package/api/src/services/processor.ts +596 -0
  25. package/api/src/services/scheduler.ts +255 -0
  26. package/api/src/services/supabase.ts +144 -0
  27. package/api/src/utils/contentCleaner.ts +114 -0
  28. package/api/src/utils/crypto.ts +80 -0
  29. package/api/src/utils/logger.ts +142 -0
  30. package/bin/email-automator-deploy.js +79 -0
  31. package/bin/email-automator-setup.js +144 -0
  32. package/bin/email-automator.js +61 -0
  33. package/dist/assets/index-BQ1uMdFh.js +97 -0
  34. package/dist/assets/index-Dzi17fx5.css +1 -0
  35. package/dist/email-automator-logo.svg +51 -0
  36. package/dist/favicon.svg +45 -0
  37. package/dist/index.html +14 -0
  38. package/index.html +13 -0
  39. package/package.json +112 -0
  40. package/public/email-automator-logo.svg +51 -0
  41. package/public/favicon.svg +45 -0
  42. package/scripts/deploy-functions.sh +55 -0
  43. package/scripts/migrate.sh +177 -0
  44. package/src/App.tsx +622 -0
  45. package/src/components/AccountSettings.tsx +310 -0
  46. package/src/components/AccountSettingsPage.tsx +390 -0
  47. package/src/components/Configuration.tsx +1345 -0
  48. package/src/components/Dashboard.tsx +940 -0
  49. package/src/components/ErrorBoundary.tsx +71 -0
  50. package/src/components/LiveTerminal.tsx +308 -0
  51. package/src/components/LoadingSpinner.tsx +39 -0
  52. package/src/components/Login.tsx +371 -0
  53. package/src/components/Logo.tsx +57 -0
  54. package/src/components/SetupWizard.tsx +388 -0
  55. package/src/components/Toast.tsx +109 -0
  56. package/src/components/migration/MigrationBanner.tsx +97 -0
  57. package/src/components/migration/MigrationModal.tsx +458 -0
  58. package/src/components/migration/MigrationPulseIndicator.tsx +38 -0
  59. package/src/components/mode-toggle.tsx +24 -0
  60. package/src/components/theme-provider.tsx +72 -0
  61. package/src/components/ui/alert.tsx +66 -0
  62. package/src/components/ui/button.tsx +57 -0
  63. package/src/components/ui/card.tsx +75 -0
  64. package/src/components/ui/dialog.tsx +133 -0
  65. package/src/components/ui/input.tsx +22 -0
  66. package/src/components/ui/label.tsx +24 -0
  67. package/src/components/ui/otp-input.tsx +184 -0
  68. package/src/context/AppContext.tsx +422 -0
  69. package/src/context/MigrationContext.tsx +53 -0
  70. package/src/context/TerminalContext.tsx +31 -0
  71. package/src/core/actions.ts +76 -0
  72. package/src/core/auth.ts +108 -0
  73. package/src/core/intelligence.ts +76 -0
  74. package/src/core/processor.ts +112 -0
  75. package/src/hooks/useRealtimeEmails.ts +111 -0
  76. package/src/index.css +140 -0
  77. package/src/lib/api-config.ts +42 -0
  78. package/src/lib/api-old.ts +228 -0
  79. package/src/lib/api.ts +421 -0
  80. package/src/lib/migration-check.ts +264 -0
  81. package/src/lib/sounds.ts +120 -0
  82. package/src/lib/supabase-config.ts +117 -0
  83. package/src/lib/supabase.ts +28 -0
  84. package/src/lib/types.ts +166 -0
  85. package/src/lib/utils.ts +6 -0
  86. package/src/main.tsx +10 -0
  87. package/supabase/.env.example +15 -0
  88. package/supabase/.temp/cli-latest +1 -0
  89. package/supabase/.temp/gotrue-version +1 -0
  90. package/supabase/.temp/pooler-url +1 -0
  91. package/supabase/.temp/postgres-version +1 -0
  92. package/supabase/.temp/project-ref +1 -0
  93. package/supabase/.temp/rest-version +1 -0
  94. package/supabase/.temp/storage-migration +1 -0
  95. package/supabase/.temp/storage-version +1 -0
  96. package/supabase/config.toml +95 -0
  97. package/supabase/functions/_shared/auth-helper.ts +76 -0
  98. package/supabase/functions/_shared/auth.ts +33 -0
  99. package/supabase/functions/_shared/cors.ts +45 -0
  100. package/supabase/functions/_shared/encryption.ts +70 -0
  101. package/supabase/functions/_shared/supabaseAdmin.ts +14 -0
  102. package/supabase/functions/api-v1-accounts/index.ts +133 -0
  103. package/supabase/functions/api-v1-emails/index.ts +177 -0
  104. package/supabase/functions/api-v1-rules/index.ts +177 -0
  105. package/supabase/functions/api-v1-settings/index.ts +247 -0
  106. package/supabase/functions/auth-gmail/index.ts +197 -0
  107. package/supabase/functions/auth-microsoft/index.ts +215 -0
  108. package/supabase/functions/setup/index.ts +92 -0
  109. package/supabase/migrations/20260114000000_initial_schema.sql +81 -0
  110. package/supabase/migrations/20260115000000_add_user_settings.sql +49 -0
  111. package/supabase/migrations/20260115000001_add_auth_flow.sql +80 -0
  112. package/supabase/migrations/20260115000002_fix_permissions.sql +5 -0
  113. package/supabase/migrations/20260115000003_fix_init_state_permissions.sql +9 -0
  114. package/supabase/migrations/20260115000004_add_migration_rpc.sql +13 -0
  115. package/supabase/migrations/20260115000005_add_provider_creds.sql +7 -0
  116. package/supabase/migrations/20260115000006_backfill_profiles.sql +22 -0
  117. package/supabase/migrations/20260116000000_add_sync_scope.sql +15 -0
  118. package/supabase/migrations/20260116000001_per_account_sync_scope.sql +19 -0
  119. package/supabase/migrations/20260116000002_add_llm_api_key.sql +5 -0
  120. package/supabase/migrations/20260117000000_refactor_integrations.sql +36 -0
  121. package/supabase/migrations/20260117000001_add_processing_events.sql +30 -0
  122. package/supabase/migrations/20260117000002_multi_actions.sql +15 -0
  123. package/supabase/migrations/20260117000003_seed_default_rules.sql +77 -0
  124. package/supabase/migrations/20260117000004_rule_instructions.sql +5 -0
  125. package/supabase/migrations/20260117000005_rule_attachments.sql +7 -0
  126. package/supabase/migrations/20260117000006_setup_storage.sql +32 -0
  127. package/supabase/migrations/20260117000007_add_system_logs.sql +26 -0
  128. package/supabase/migrations/20260117000008_link_logs_to_accounts.sql +8 -0
  129. package/supabase/migrations/20260117000009_convert_toggles_to_rules.sql +28 -0
  130. package/supabase/migrations/20260117000010_add_atomic_action_append.sql +13 -0
  131. package/supabase/migrations/20260117000011_add_profile_avatar.sql +4 -0
  132. package/supabase/migrations/20260117000012_setup_avatars_storage.sql +26 -0
  133. package/supabase/templates/confirmation.html +76 -0
  134. package/supabase/templates/email-change.html +76 -0
  135. package/supabase/templates/invite.html +72 -0
  136. package/supabase/templates/magic-link.html +68 -0
  137. package/supabase/templates/recovery.html +82 -0
  138. package/tsconfig.json +36 -0
  139. package/vite.config.ts +162 -0
@@ -0,0 +1,152 @@
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
+
9
+ const router = Router();
10
+ const logger = createLogger('SyncRoutes');
11
+
12
+ // Trigger sync for an account
13
+ router.post('/',
14
+ syncRateLimit,
15
+ authMiddleware,
16
+ validateBody(schemas.syncRequest),
17
+ asyncHandler(async (req, res) => {
18
+ const { accountId } = req.body;
19
+ const userId = req.user!.id;
20
+
21
+ if (!req.supabase) {
22
+ return res.status(503).json({
23
+ error: 'Supabase service is not configured. Please set your SUPABASE_URL and SUPABASE_ANON_KEY in the .env file and restart the server.'
24
+ });
25
+ }
26
+
27
+ // Verify account ownership
28
+ const { data: account, error } = await req.supabase!
29
+ .from('email_accounts')
30
+ .select('id')
31
+ .eq('id', accountId)
32
+ .eq('user_id', userId)
33
+ .single();
34
+
35
+ if (error || !account) {
36
+ return res.status(404).json({ error: 'Account not found' });
37
+ }
38
+
39
+ // Run sync and wait for result
40
+ const processor = new EmailProcessorService(req.supabase!);
41
+
42
+ try {
43
+ const result = await processor.syncAccount(accountId, userId);
44
+ logger.info('Sync completed', { accountId, ...result });
45
+ res.json({
46
+ message: 'Sync completed',
47
+ accountId,
48
+ ...result,
49
+ });
50
+ } catch (err) {
51
+ logger.error('Sync failed', err, { accountId });
52
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
53
+ res.status(500).json({
54
+ error: errorMessage,
55
+ accountId,
56
+ });
57
+ }
58
+ })
59
+ );
60
+
61
+ // Sync all accounts for user
62
+ router.post('/all',
63
+ syncRateLimit,
64
+ authMiddleware,
65
+ asyncHandler(async (req, res) => {
66
+ const userId = req.user!.id;
67
+
68
+ if (!req.supabase) {
69
+ return res.status(503).json({
70
+ error: 'Supabase service is not configured. Please set your SUPABASE_URL and SUPABASE_ANON_KEY in the .env file and restart the server.'
71
+ });
72
+ }
73
+
74
+ const { data: accounts, error } = await req.supabase!
75
+ .from('email_accounts')
76
+ .select('id')
77
+ .eq('user_id', userId)
78
+ .eq('is_active', true);
79
+
80
+ if (error) throw error;
81
+
82
+ if (!accounts || accounts.length === 0) {
83
+ return res.status(400).json({ error: 'No connected accounts' });
84
+ }
85
+
86
+ const processor = new EmailProcessorService(req.supabase!);
87
+
88
+ // Sync all accounts and collect results
89
+ const results = await Promise.allSettled(
90
+ accounts.map(account => processor.syncAccount(account.id, userId))
91
+ );
92
+
93
+ const summary = {
94
+ total: accounts.length,
95
+ success: 0,
96
+ failed: 0,
97
+ errors: [] as { accountId: string; error: string }[],
98
+ };
99
+
100
+ results.forEach((result, index) => {
101
+ if (result.status === 'fulfilled') {
102
+ summary.success++;
103
+ logger.info('Sync completed', { accountId: accounts[index].id, ...result.value });
104
+ } else {
105
+ summary.failed++;
106
+ const errorMessage = result.reason instanceof Error ? result.reason.message : 'Unknown error';
107
+ summary.errors.push({ accountId: accounts[index].id, error: errorMessage });
108
+ logger.error('Sync failed', result.reason, { accountId: accounts[index].id });
109
+ }
110
+ });
111
+
112
+ // Return error status if all syncs failed
113
+ if (summary.failed === summary.total) {
114
+ return res.status(500).json({
115
+ message: 'All syncs failed',
116
+ ...summary,
117
+ });
118
+ }
119
+
120
+ res.json({
121
+ message: summary.failed > 0 ? 'Sync completed with errors' : 'Sync completed',
122
+ ...summary,
123
+ });
124
+ })
125
+ );
126
+
127
+ // Get sync history/logs
128
+ router.get('/logs',
129
+ authMiddleware,
130
+ asyncHandler(async (req, res) => {
131
+ const { limit = '10' } = req.query;
132
+
133
+ if (!req.supabase) {
134
+ return res.status(503).json({
135
+ error: 'Supabase service is not configured. Please set your SUPABASE_URL and SUPABASE_ANON_KEY in the .env file and restart the server.'
136
+ });
137
+ }
138
+
139
+ const { data, error } = await req.supabase!
140
+ .from('processing_logs')
141
+ .select('*')
142
+ .eq('user_id', req.user!.id)
143
+ .order('started_at', { ascending: false })
144
+ .limit(parseInt(limit as string, 10));
145
+
146
+ if (error) throw error;
147
+
148
+ res.json({ logs: data || [] });
149
+ })
150
+ );
151
+
152
+ export default router;
@@ -0,0 +1,52 @@
1
+ import { SupabaseClient } from '@supabase/supabase-js';
2
+ import { createLogger } from '../utils/logger.js';
3
+
4
+ const logger = createLogger('EventLogger');
5
+
6
+ export class EventLogger {
7
+ constructor(
8
+ private supabase: SupabaseClient,
9
+ private runId: string
10
+ ) {}
11
+
12
+ async log(
13
+ eventType: 'info' | 'analysis' | 'action' | 'error',
14
+ agentState: string,
15
+ details?: any,
16
+ emailId?: string
17
+ ) {
18
+ try {
19
+ const { error } = await this.supabase.from('processing_events').insert({
20
+ run_id: this.runId,
21
+ email_id: emailId || null,
22
+ event_type: eventType,
23
+ agent_state: agentState,
24
+ details: details || {},
25
+ created_at: new Date().toISOString()
26
+ });
27
+
28
+ if (error) {
29
+ console.error('[EventLogger] Supabase Insert Error:', error);
30
+ }
31
+ } catch (error) {
32
+ // Non-blocking error logging - don't fail the job because logging failed
33
+ logger.error('Failed to write processing event', error);
34
+ }
35
+ }
36
+
37
+ async info(state: string, message: string, details?: any, emailId?: string) {
38
+ await this.log('info', state, { message, ...details }, emailId);
39
+ }
40
+
41
+ async analysis(state: string, emailId: string, analysis: any) {
42
+ await this.log('analysis', state, analysis, emailId);
43
+ }
44
+
45
+ async action(state: string, emailId: string, action: string, reason?: string) {
46
+ await this.log('action', state, { action, reason }, emailId);
47
+ }
48
+
49
+ async error(state: string, error: any, emailId?: string) {
50
+ await this.log('error', state, { error: error.message || error }, emailId);
51
+ }
52
+ }
@@ -0,0 +1,456 @@
1
+ import { google, gmail_v1, Auth } from 'googleapis';
2
+ import { SupabaseClient } from '@supabase/supabase-js';
3
+ import { config } from '../config/index.js';
4
+ import { createLogger } from '../utils/logger.js';
5
+ // Tokens are stored without encryption, protected by Supabase RLS
6
+ import { EmailAccount } from './supabase.js';
7
+
8
+ const logger = createLogger('GmailService');
9
+
10
+ export interface RuleAttachment {
11
+ name: string;
12
+ path: string;
13
+ type: string;
14
+ size: number;
15
+ }
16
+
17
+ export interface GmailMessage {
18
+ id: string;
19
+ threadId: string;
20
+ subject: string;
21
+ sender: string;
22
+ recipient: string;
23
+ date: string;
24
+ body: string;
25
+ snippet: string;
26
+ headers: {
27
+ importance?: string;
28
+ listUnsubscribe?: string;
29
+ autoSubmitted?: string;
30
+ mailer?: string;
31
+ };
32
+ }
33
+
34
+ export interface OAuthCredentials {
35
+ clientId: string;
36
+ clientSecret: string;
37
+ redirectUri?: string;
38
+ }
39
+
40
+ export class GmailService {
41
+ private createOAuth2Client(credentials?: OAuthCredentials): Auth.OAuth2Client {
42
+ return new google.auth.OAuth2(
43
+ credentials?.clientId || config.gmail.clientId,
44
+ credentials?.clientSecret || config.gmail.clientSecret,
45
+ credentials?.redirectUri || config.gmail.redirectUri
46
+ );
47
+ }
48
+
49
+ async getProviderCredentials(supabase: SupabaseClient, userId: string): Promise<OAuthCredentials> {
50
+ const { data: integration } = await supabase
51
+ .from('integrations')
52
+ .select('credentials')
53
+ .eq('user_id', userId)
54
+ .eq('provider', 'google')
55
+ .single();
56
+
57
+ const creds = integration?.credentials as any;
58
+
59
+ if (creds?.client_id && creds?.client_secret) {
60
+ return {
61
+ clientId: creds.client_id,
62
+ clientSecret: creds.client_secret,
63
+ redirectUri: config.gmail.redirectUri
64
+ };
65
+ }
66
+
67
+ if (config.gmail.clientId && config.gmail.clientSecret) {
68
+ return {
69
+ clientId: config.gmail.clientId,
70
+ clientSecret: config.gmail.clientSecret,
71
+ redirectUri: config.gmail.redirectUri
72
+ };
73
+ }
74
+
75
+ throw new Error('Gmail OAuth credentials not configured (Database or Env)');
76
+ }
77
+
78
+ getAuthUrl(scopes: string[] = ['https://www.googleapis.com/auth/gmail.modify']): string {
79
+ const client = this.createOAuth2Client();
80
+ return client.generateAuthUrl({
81
+ access_type: 'offline',
82
+ scope: scopes,
83
+ prompt: 'consent',
84
+ });
85
+ }
86
+
87
+ async exchangeCode(code: string): Promise<{
88
+ access_token: string;
89
+ refresh_token?: string;
90
+ expiry_date?: number;
91
+ scope?: string;
92
+ }> {
93
+ const client = this.createOAuth2Client();
94
+ const { tokens } = await client.getToken(code);
95
+ return {
96
+ access_token: tokens.access_token!,
97
+ refresh_token: tokens.refresh_token ?? undefined,
98
+ expiry_date: tokens.expiry_date ?? undefined,
99
+ scope: tokens.scope ?? undefined,
100
+ };
101
+ }
102
+
103
+ async saveAccount(
104
+ supabase: SupabaseClient,
105
+ userId: string,
106
+ emailAddress: string,
107
+ tokens: { access_token: string; refresh_token?: string; expiry_date?: number; scope?: string }
108
+ ): Promise<EmailAccount> {
109
+ const { data, error } = await supabase
110
+ .from('email_accounts')
111
+ .upsert({
112
+ user_id: userId,
113
+ email_address: emailAddress,
114
+ provider: 'gmail',
115
+ access_token: tokens.access_token,
116
+ refresh_token: tokens.refresh_token || null,
117
+ token_expires_at: tokens.expiry_date ? new Date(tokens.expiry_date).toISOString() : null,
118
+ scopes: tokens.scope?.split(' ') || [],
119
+ is_active: true,
120
+ updated_at: new Date().toISOString(),
121
+ }, { onConflict: 'user_id, email_address' })
122
+ .select()
123
+ .single();
124
+
125
+ if (error) throw error;
126
+ return data;
127
+ }
128
+
129
+ private async getAuthenticatedClient(account: EmailAccount): Promise<gmail_v1.Gmail> {
130
+ const accessToken = account.access_token || '';
131
+ const refreshToken = account.refresh_token || '';
132
+ const client = this.createOAuth2Client();
133
+
134
+ client.setCredentials({
135
+ access_token: accessToken,
136
+ refresh_token: refreshToken,
137
+ expiry_date: account.token_expires_at ? new Date(account.token_expires_at).getTime() : undefined,
138
+ });
139
+
140
+ return google.gmail({ version: 'v1', auth: client });
141
+ }
142
+
143
+ async refreshTokenIfNeeded(
144
+ supabase: SupabaseClient,
145
+ account: EmailAccount
146
+ ): Promise<EmailAccount> {
147
+ if (!account.token_expires_at) return account;
148
+
149
+ const expiresAt = new Date(account.token_expires_at).getTime();
150
+ const now = Date.now();
151
+ const bufferMs = 5 * 60 * 1000; // 5 minutes buffer
152
+
153
+ if (expiresAt > now + bufferMs) {
154
+ return account; // Token still valid
155
+ }
156
+
157
+ logger.info('Refreshing Gmail token', { accountId: account.id });
158
+
159
+ const refreshToken = account.refresh_token;
160
+ if (!refreshToken) {
161
+ throw new Error('No refresh token available');
162
+ }
163
+
164
+ const credentials = await this.getProviderCredentials(supabase, account.user_id);
165
+ const client = this.createOAuth2Client(credentials);
166
+ client.setCredentials({ refresh_token: refreshToken });
167
+ const { credentials: newTokens } = await client.refreshAccessToken();
168
+
169
+ const { data, error } = await supabase
170
+ .from('email_accounts')
171
+ .update({
172
+ access_token: newTokens.access_token!,
173
+ token_expires_at: newTokens.expiry_date
174
+ ? new Date(newTokens.expiry_date).toISOString()
175
+ : null,
176
+ updated_at: new Date().toISOString(),
177
+ })
178
+ .eq('id', account.id)
179
+ .select()
180
+ .single();
181
+
182
+ if (error) throw error;
183
+ return data;
184
+ }
185
+
186
+ async fetchMessages(
187
+ account: EmailAccount,
188
+ options: { maxResults?: number; query?: string; pageToken?: string } = {}
189
+ ): Promise<{ messages: GmailMessage[]; nextPageToken?: string }> {
190
+ const gmail = await this.getAuthenticatedClient(account);
191
+ const { maxResults = config.processing.batchSize, query, pageToken } = options;
192
+
193
+ const response = await gmail.users.messages.list({
194
+ userId: 'me',
195
+ maxResults,
196
+ q: query,
197
+ pageToken,
198
+ });
199
+
200
+ const messages: GmailMessage[] = [];
201
+
202
+ for (const msg of response.data.messages || []) {
203
+ if (!msg.id) continue;
204
+
205
+ try {
206
+ const detail = await gmail.users.messages.get({
207
+ userId: 'me',
208
+ id: msg.id,
209
+ format: 'full',
210
+ });
211
+
212
+ const parsed = this.parseMessage(detail.data);
213
+ if (parsed) {
214
+ messages.push(parsed);
215
+ }
216
+ } catch (error) {
217
+ logger.warn('Failed to fetch message details', { messageId: msg.id, error });
218
+ }
219
+ }
220
+
221
+ return {
222
+ messages,
223
+ nextPageToken: response.data.nextPageToken ?? undefined,
224
+ };
225
+ }
226
+
227
+ private parseMessage(message: gmail_v1.Schema$Message): GmailMessage | null {
228
+ if (!message.id || !message.threadId) return null;
229
+
230
+ const headers = message.payload?.headers || [];
231
+ const getHeader = (name: string) => headers.find(h => h.name?.toLowerCase() === name.toLowerCase())?.value || '';
232
+
233
+ let body = '';
234
+ const payload = message.payload;
235
+
236
+ if (payload?.parts) {
237
+ // Multipart message
238
+ const textPart = payload.parts.find(p => p.mimeType === 'text/plain');
239
+ const htmlPart = payload.parts.find(p => p.mimeType === 'text/html');
240
+ const part = textPart || htmlPart || payload.parts[0];
241
+ body = this.decodeBody(part?.body?.data);
242
+ } else if (payload?.body?.data) {
243
+ body = this.decodeBody(payload.body.data);
244
+ }
245
+
246
+ return {
247
+ id: message.id,
248
+ threadId: message.threadId,
249
+ subject: getHeader('Subject') || 'No Subject',
250
+ sender: getHeader('From'),
251
+ recipient: getHeader('To'),
252
+ date: getHeader('Date'),
253
+ body,
254
+ snippet: message.snippet || '',
255
+ headers: {
256
+ importance: getHeader('Importance') || getHeader('X-Priority'),
257
+ listUnsubscribe: getHeader('List-Unsubscribe'),
258
+ autoSubmitted: getHeader('Auto-Submitted'),
259
+ mailer: getHeader('X-Mailer'),
260
+ }
261
+ };
262
+ }
263
+
264
+ private decodeBody(data?: string | null): string {
265
+ if (!data) return '';
266
+ try {
267
+ return Buffer.from(data, 'base64').toString('utf-8');
268
+ } catch {
269
+ return '';
270
+ }
271
+ }
272
+
273
+ async trashMessage(account: EmailAccount, messageId: string): Promise<void> {
274
+ const gmail = await this.getAuthenticatedClient(account);
275
+ await gmail.users.messages.trash({ userId: 'me', id: messageId });
276
+ logger.debug('Message trashed', { messageId });
277
+ }
278
+
279
+ async archiveMessage(account: EmailAccount, messageId: string): Promise<void> {
280
+ const gmail = await this.getAuthenticatedClient(account);
281
+ await gmail.users.messages.modify({
282
+ userId: 'me',
283
+ id: messageId,
284
+ requestBody: { removeLabelIds: ['INBOX'] },
285
+ });
286
+ logger.debug('Message archived', { messageId });
287
+ }
288
+
289
+ async createDraft(
290
+ account: EmailAccount,
291
+ originalMessageId: string,
292
+ replyContent: string,
293
+ supabase?: SupabaseClient,
294
+ attachments?: RuleAttachment[]
295
+ ): Promise<string> {
296
+ const gmail = await this.getAuthenticatedClient(account);
297
+
298
+ // Fetch original message to get threadId and Message-ID for threading
299
+ const original = await gmail.users.messages.get({ userId: 'me', id: originalMessageId });
300
+ const headers = original.data.payload?.headers || [];
301
+ const getHeader = (name: string) => headers.find(h => h.name?.toLowerCase() === name.toLowerCase())?.value || '';
302
+
303
+ const toAddress = getHeader('From');
304
+ const originalSubject = getHeader('Subject');
305
+ const originalMsgId = getHeader('Message-ID');
306
+ const threadId = original.data.threadId;
307
+
308
+ // Ensure subject has Re: prefix
309
+ const subject = originalSubject.toLowerCase().startsWith('re:')
310
+ ? originalSubject
311
+ : `Re: ${originalSubject}`;
312
+
313
+ logger.info('Creating draft', { threadId, toAddress, subject });
314
+
315
+ // Threading headers: In-Reply-To should be the Message-ID of the mail we reply to
316
+ const replyHeaders = [];
317
+ if (originalMsgId) {
318
+ replyHeaders.push(`In-Reply-To: ${originalMsgId}`);
319
+ replyHeaders.push(`References: ${originalMsgId}`);
320
+ }
321
+
322
+ let rawMessage = '';
323
+ const boundary = `----=_Part_${Math.random().toString(36).substring(2)}`;
324
+
325
+ if (attachments && attachments.length > 0 && supabase) {
326
+ // Multipart message
327
+ rawMessage = [
328
+ `To: ${toAddress}`,
329
+ `Subject: ${subject}`,
330
+ ...replyHeaders,
331
+ 'MIME-Version: 1.0',
332
+ `Content-Type: multipart/mixed; boundary="${boundary}"`,
333
+ '',
334
+ `--${boundary}`,
335
+ 'Content-Type: text/plain; charset="UTF-8"',
336
+ 'Content-Transfer-Encoding: 7bit',
337
+ '',
338
+ replyContent,
339
+ '',
340
+ ].join('\r\n');
341
+
342
+ for (const attachment of attachments) {
343
+ try {
344
+ const content = await this.fetchAttachment(supabase, attachment.path);
345
+ const base64Content = Buffer.from(content).toString('base64');
346
+
347
+ rawMessage += [
348
+ `--${boundary}`,
349
+ `Content-Type: ${attachment.type}; name="${attachment.name}"`,
350
+ `Content-Disposition: attachment; filename="${attachment.name}"`,
351
+ 'Content-Transfer-Encoding: base64',
352
+ '',
353
+ base64Content,
354
+ '',
355
+ ].join('\r\n');
356
+ } catch (err) {
357
+ logger.error('Failed to attach file', err, { path: attachment.path });
358
+ }
359
+ }
360
+
361
+ rawMessage += `--${boundary}--`;
362
+ } else {
363
+ // Simple plain text message
364
+ rawMessage = [
365
+ `To: ${toAddress}`,
366
+ `Subject: ${subject}`,
367
+ ...replyHeaders,
368
+ 'MIME-Version: 1.0',
369
+ 'Content-Type: text/plain; charset="UTF-8"',
370
+ '',
371
+ replyContent,
372
+ ].join('\r\n');
373
+ }
374
+
375
+ const encodedMessage = Buffer.from(rawMessage)
376
+ .toString('base64')
377
+ .replace(/\+/g, '-')
378
+ .replace(/\//g, '_')
379
+ .replace(/=+$/, '');
380
+
381
+ try {
382
+ const draft = await gmail.users.drafts.create({
383
+ userId: 'me',
384
+ requestBody: {
385
+ message: {
386
+ threadId,
387
+ raw: encodedMessage,
388
+ },
389
+ },
390
+ });
391
+
392
+ const draftId = draft.data.id || 'unknown';
393
+ logger.info('Draft created successfully', { draftId, threadId });
394
+ return draftId;
395
+ } catch (error) {
396
+ logger.error('Gmail API Error creating draft', error);
397
+ throw error;
398
+ }
399
+ }
400
+
401
+ async addLabel(account: EmailAccount, messageId: string, labelIds: string[]): Promise<void> {
402
+ const gmail = await this.getAuthenticatedClient(account);
403
+ await gmail.users.messages.modify({
404
+ userId: 'me',
405
+ id: messageId,
406
+ requestBody: { addLabelIds: labelIds },
407
+ });
408
+ }
409
+
410
+ async removeLabel(account: EmailAccount, messageId: string, labelIds: string[]): Promise<void> {
411
+ const gmail = await this.getAuthenticatedClient(account);
412
+ await gmail.users.messages.modify({
413
+ userId: 'me',
414
+ id: messageId,
415
+ requestBody: { removeLabelIds: labelIds },
416
+ });
417
+ }
418
+
419
+ async markAsRead(account: EmailAccount, messageId: string): Promise<void> {
420
+ await this.removeLabel(account, messageId, ['UNREAD']);
421
+ logger.debug('Message marked as read', { messageId });
422
+ }
423
+
424
+ async starMessage(account: EmailAccount, messageId: string): Promise<void> {
425
+ await this.addLabel(account, messageId, ['STARRED']);
426
+ logger.debug('Message starred', { messageId });
427
+ }
428
+
429
+ private async fetchAttachment(supabase: SupabaseClient, path: string): Promise<Uint8Array> {
430
+ const { data, error } = await supabase.storage
431
+ .from('rule-attachments')
432
+ .download(path);
433
+
434
+ if (error) throw error;
435
+ return new Uint8Array(await data.arrayBuffer());
436
+ }
437
+
438
+ async getProfile(account: EmailAccount): Promise<{ emailAddress: string; messagesTotal: number }> {
439
+ const gmail = await this.getAuthenticatedClient(account);
440
+ const profile = await gmail.users.getProfile({ userId: 'me' });
441
+ return {
442
+ emailAddress: profile.data.emailAddress || '',
443
+ messagesTotal: profile.data.messagesTotal || 0,
444
+ };
445
+ }
446
+ }
447
+
448
+ // Singleton
449
+ let instance: GmailService | null = null;
450
+
451
+ export function getGmailService(): GmailService {
452
+ if (!instance) {
453
+ instance = new GmailService();
454
+ }
455
+ return instance;
456
+ }