@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,255 @@
1
+ import { SupabaseClient } from '@supabase/supabase-js';
2
+ import { config } from '../config/index.js';
3
+ import { createLogger } from '../utils/logger.js';
4
+ import { EmailProcessorService } from './processor.js';
5
+ import { getServerSupabase } from './supabase.js';
6
+
7
+ const logger = createLogger('Scheduler');
8
+
9
+ interface ScheduledJob {
10
+ id: string;
11
+ name: string;
12
+ interval: number;
13
+ lastRun: Date | null;
14
+ isRunning: boolean;
15
+ timer: NodeJS.Timeout | null;
16
+ }
17
+
18
+ class SyncScheduler {
19
+ private jobs: Map<string, ScheduledJob> = new Map();
20
+ private supabase: SupabaseClient | null = null;
21
+
22
+ constructor() {
23
+ this.supabase = getServerSupabase();
24
+ }
25
+
26
+ async start(): Promise<void> {
27
+ if (!this.supabase) {
28
+ logger.warn('Supabase not configured, scheduler disabled');
29
+ return;
30
+ }
31
+
32
+ logger.info('Starting sync scheduler');
33
+
34
+ // Schedule periodic sync for all active accounts
35
+ this.scheduleGlobalSync();
36
+
37
+ // Schedule cleanup job
38
+ this.scheduleCleanup();
39
+ }
40
+
41
+ stop(): void {
42
+ logger.info('Stopping sync scheduler');
43
+ for (const job of this.jobs.values()) {
44
+ if (job.timer) {
45
+ clearInterval(job.timer);
46
+ }
47
+ }
48
+ this.jobs.clear();
49
+ }
50
+
51
+ private scheduleGlobalSync(): void {
52
+ const jobId = 'global-sync';
53
+ const interval = config.processing.syncIntervalMs;
54
+
55
+ const job: ScheduledJob = {
56
+ id: jobId,
57
+ name: 'Global Email Sync',
58
+ interval,
59
+ lastRun: null,
60
+ isRunning: false,
61
+ timer: null,
62
+ };
63
+
64
+ job.timer = setInterval(async () => {
65
+ if (job.isRunning) {
66
+ logger.debug('Global sync already running, skipping');
67
+ return;
68
+ }
69
+
70
+ job.isRunning = true;
71
+ try {
72
+ await this.runGlobalSync();
73
+ job.lastRun = new Date();
74
+ } catch (error) {
75
+ logger.error('Global sync failed', error);
76
+ } finally {
77
+ job.isRunning = false;
78
+ }
79
+ }, interval);
80
+
81
+ this.jobs.set(jobId, job);
82
+ logger.info(`Scheduled global sync every ${interval / 1000}s`);
83
+ }
84
+
85
+ private async runGlobalSync(): Promise<void> {
86
+ if (!this.supabase) return;
87
+
88
+ // Get all active accounts with their user settings
89
+ const { data: accounts, error } = await this.supabase
90
+ .from('email_accounts')
91
+ .select(`
92
+ id,
93
+ user_id,
94
+ provider,
95
+ is_active
96
+ `)
97
+ .eq('is_active', true);
98
+
99
+ if (error) {
100
+ logger.error('Failed to fetch accounts for sync', error);
101
+ return;
102
+ }
103
+
104
+ if (!accounts || accounts.length === 0) {
105
+ logger.debug('No active accounts to sync');
106
+ return;
107
+ }
108
+
109
+ logger.info(`Running global sync for ${accounts.length} accounts`);
110
+
111
+ // Group by user to check their sync interval settings
112
+ const userAccounts = new Map<string, typeof accounts>();
113
+ for (const account of accounts) {
114
+ const existing = userAccounts.get(account.user_id) || [];
115
+ existing.push(account);
116
+ userAccounts.set(account.user_id, existing);
117
+ }
118
+
119
+ // Process each user's accounts
120
+ for (const [userId, userAccountList] of userAccounts) {
121
+ // Check user's sync interval preference
122
+ const { data: settings } = await this.supabase
123
+ .from('user_settings')
124
+ .select('sync_interval_minutes')
125
+ .eq('user_id', userId)
126
+ .single();
127
+
128
+ const syncIntervalMs = (settings?.sync_interval_minutes || 5) * 60 * 1000;
129
+
130
+ // Check last sync time
131
+ const { data: lastLog } = await this.supabase
132
+ .from('processing_logs')
133
+ .select('started_at')
134
+ .eq('user_id', userId)
135
+ .eq('status', 'success')
136
+ .order('started_at', { ascending: false })
137
+ .limit(1)
138
+ .single();
139
+
140
+ if (lastLog) {
141
+ const lastSyncTime = new Date(lastLog.started_at).getTime();
142
+ const now = Date.now();
143
+ if (now - lastSyncTime < syncIntervalMs) {
144
+ logger.debug(`Skipping sync for user ${userId}, last sync was recent`);
145
+ continue;
146
+ }
147
+ }
148
+
149
+ // Sync each account
150
+ const processor = new EmailProcessorService(this.supabase);
151
+ for (const account of userAccountList) {
152
+ try {
153
+ await processor.syncAccount(account.id, userId);
154
+ } catch (error) {
155
+ logger.error('Account sync failed', error, { accountId: account.id });
156
+ }
157
+ }
158
+ }
159
+ }
160
+
161
+ private scheduleCleanup(): void {
162
+ const jobId = 'cleanup';
163
+ const interval = 24 * 60 * 60 * 1000; // Daily
164
+
165
+ const job: ScheduledJob = {
166
+ id: jobId,
167
+ name: 'Data Cleanup',
168
+ interval,
169
+ lastRun: null,
170
+ isRunning: false,
171
+ timer: null,
172
+ };
173
+
174
+ job.timer = setInterval(async () => {
175
+ if (job.isRunning) return;
176
+
177
+ job.isRunning = true;
178
+ try {
179
+ await this.runCleanup();
180
+ job.lastRun = new Date();
181
+ } catch (error) {
182
+ logger.error('Cleanup failed', error);
183
+ } finally {
184
+ job.isRunning = false;
185
+ }
186
+ }, interval);
187
+
188
+ this.jobs.set(jobId, job);
189
+ logger.info('Scheduled daily cleanup');
190
+ }
191
+
192
+ private async runCleanup(): Promise<void> {
193
+ if (!this.supabase) return;
194
+
195
+ logger.info('Running cleanup job');
196
+
197
+ // Delete old processing logs (older than 30 days)
198
+ const thirtyDaysAgo = new Date();
199
+ thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
200
+
201
+ const { error: logsError } = await this.supabase
202
+ .from('processing_logs')
203
+ .delete()
204
+ .lt('started_at', thirtyDaysAgo.toISOString());
205
+
206
+ if (logsError) {
207
+ logger.error('Failed to cleanup old logs', logsError);
208
+ }
209
+
210
+ // Delete emails that were trashed more than 7 days ago
211
+ const sevenDaysAgo = new Date();
212
+ sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
213
+
214
+ const { error: emailsError } = await this.supabase
215
+ .from('emails')
216
+ .delete()
217
+ .eq('action_taken', 'delete')
218
+ .lt('created_at', sevenDaysAgo.toISOString());
219
+
220
+ if (emailsError) {
221
+ logger.error('Failed to cleanup old emails', emailsError);
222
+ }
223
+
224
+ logger.info('Cleanup completed');
225
+ }
226
+
227
+ getJobStatus(): Array<{ id: string; name: string; lastRun: Date | null; isRunning: boolean }> {
228
+ return Array.from(this.jobs.values()).map(job => ({
229
+ id: job.id,
230
+ name: job.name,
231
+ lastRun: job.lastRun,
232
+ isRunning: job.isRunning,
233
+ }));
234
+ }
235
+ }
236
+
237
+ // Singleton
238
+ let schedulerInstance: SyncScheduler | null = null;
239
+
240
+ export function getScheduler(): SyncScheduler {
241
+ if (!schedulerInstance) {
242
+ schedulerInstance = new SyncScheduler();
243
+ }
244
+ return schedulerInstance;
245
+ }
246
+
247
+ export function startScheduler(): void {
248
+ getScheduler().start();
249
+ }
250
+
251
+ export function stopScheduler(): void {
252
+ if (schedulerInstance) {
253
+ schedulerInstance.stop();
254
+ }
255
+ }
@@ -0,0 +1,144 @@
1
+ import { createClient, SupabaseClient } from '@supabase/supabase-js';
2
+ import { config } from '../config/index.js';
3
+ import { createLogger } from '../utils/logger.js';
4
+
5
+ const logger = createLogger('SupabaseService');
6
+
7
+ let serverClient: SupabaseClient | null = null;
8
+
9
+ export function isValidUrl(url: string): boolean {
10
+ try {
11
+ return url.startsWith('http://') || url.startsWith('https://');
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+
17
+ export function getServerSupabase(): SupabaseClient | null {
18
+ if (serverClient) return serverClient;
19
+
20
+ const url = config.supabase.url;
21
+ const key = config.supabase.anonKey;
22
+
23
+ if (!url || !key || !isValidUrl(url)) {
24
+ logger.warn('Supabase not configured or invalid URL - skipping client initialization', {
25
+ url: url || 'missing'
26
+ });
27
+ return null;
28
+ }
29
+
30
+ try {
31
+ serverClient = createClient(url, key, {
32
+ auth: {
33
+ autoRefreshToken: false,
34
+ persistSession: false,
35
+ },
36
+ });
37
+
38
+ logger.info('Server Supabase client initialized');
39
+ return serverClient;
40
+ } catch (error) {
41
+ logger.error('Failed to initialize Supabase client', error);
42
+ return null;
43
+ }
44
+ }
45
+
46
+ export function getServiceRoleSupabase(): SupabaseClient | null {
47
+ const url = config.supabase.url;
48
+ const key = config.supabase.serviceRoleKey;
49
+
50
+ if (!url || !key || !isValidUrl(url)) {
51
+ logger.warn('Service role Supabase not configured or invalid URL');
52
+ return null;
53
+ }
54
+
55
+ try {
56
+ return createClient(url, key, {
57
+ auth: {
58
+ autoRefreshToken: false,
59
+ persistSession: false,
60
+ },
61
+ });
62
+ } catch (error) {
63
+ logger.error('Failed to initialize Service Role Supabase client', error);
64
+ return null;
65
+ }
66
+ }
67
+
68
+ // Database types (expand as needed)
69
+ export interface EmailAccount {
70
+ id: string;
71
+ user_id: string;
72
+ provider: 'gmail' | 'outlook';
73
+ email_address: string;
74
+ access_token: string | null;
75
+ refresh_token: string | null;
76
+ token_expires_at: string | null;
77
+ scopes: string[];
78
+ is_active: boolean;
79
+ last_sync_checkpoint?: string | null;
80
+ sync_start_date?: string | null;
81
+ sync_max_emails_per_run?: number;
82
+ last_sync_at?: string | null;
83
+ last_sync_status?: 'idle' | 'syncing' | 'success' | 'error';
84
+ last_sync_error?: string | null;
85
+ created_at: string;
86
+ updated_at: string;
87
+ }
88
+
89
+ export interface Email {
90
+ id: string;
91
+ account_id: string;
92
+ external_id: string;
93
+ subject: string | null;
94
+ sender: string | null;
95
+ recipient: string | null;
96
+ date: string | null;
97
+ body_snippet: string | null;
98
+ category: string | null;
99
+ is_useless: boolean;
100
+ ai_analysis: Record<string, unknown> | null;
101
+ suggested_action: string | null; // Deprecated
102
+ suggested_actions?: string[];
103
+ action_taken: string | null; // Deprecated
104
+ actions_taken?: string[];
105
+ created_at: string;
106
+ }
107
+
108
+ export interface Rule {
109
+ id: string;
110
+ user_id: string;
111
+ name: string;
112
+ condition: Record<string, unknown>;
113
+ action: 'delete' | 'archive' | 'draft' | 'star' | 'read';
114
+ instructions?: string;
115
+ attachments?: any[];
116
+ is_enabled: boolean;
117
+ created_at: string;
118
+ }
119
+
120
+ export interface ProcessingLog {
121
+ id: string;
122
+ user_id: string;
123
+ account_id: string | null;
124
+ status: 'running' | 'success' | 'failed';
125
+ started_at: string;
126
+ completed_at: string | null;
127
+ emails_processed: number;
128
+ emails_deleted: number;
129
+ emails_drafted: number;
130
+ error_message: string | null;
131
+ }
132
+
133
+ export interface UserSettings {
134
+ id: string;
135
+ user_id: string;
136
+ llm_model: string | null;
137
+ llm_base_url: string | null;
138
+ llm_api_key: string | null;
139
+ auto_trash_spam: boolean;
140
+ smart_drafts: boolean;
141
+ sync_interval_minutes: number;
142
+ created_at: string;
143
+ updated_at: string;
144
+ }
@@ -0,0 +1,114 @@
1
+ export class ContentCleaner {
2
+ /**
3
+ * Cleans email body by removing noise, quoted replies, and footers.
4
+ * Ported from Python ContentCleaner.
5
+ */
6
+ static cleanEmailBody(text: string): string {
7
+ if (!text) return "";
8
+
9
+ // 0. Lightweight HTML -> Markdown Conversion
10
+
11
+ // Structure: <br>, <p> -> Newlines
12
+ text = text.replace(/<br\s*\/?\?>/gi, '\n');
13
+ text = text.replace(/<\/p>/gi, '\n\n');
14
+ text = text.replace(/<p.*?>/gi, ''); // Open p tags just gone
15
+
16
+ // Structure: Headers <h1>-<h6> -> # Title
17
+ text = text.replace(/<h[1-6].*?>(.*?)<\/h[1-6]>/gsi, (match, p1) => `\n# ${p1}\n`);
18
+
19
+ // Structure: Lists <li> -> - Item
20
+ text = text.replace(/<li.*?>(.*?)<\/li>/gsi, (match, p1) => `\n- ${p1}`);
21
+ text = text.replace(/<ul.*?>/gi, '');
22
+ text = text.replace(/<\/ul>/gi, '\n');
23
+
24
+ // Links: <a href=\"...\">text</a> -> [text](href)
25
+ text = text.replace(/<a\s+(?:[^>]*?\s+)?href=\"([^\"]*)\"[^>]*>(.*?)<\/a>/gsi, (match, href, content) => `[${content}](${href})`);
26
+
27
+ // Images: <img src=\"...\" alt=\"...\"> -> ![alt](src)
28
+ text = text.replace(/<img\s+(?:[^>]*?\s+)?src=\"([^\"]*)\"(?:[^>]*?\s+)?alt=\"([^\"]*)\"[^>]*>/gsi, (match, src, alt) => `![${alt}](${src})`);
29
+
30
+ // Style/Script removal (strictly remove content)
31
+ text = text.replace(/<script.*?>.*?<\/script>/gsi, '');
32
+ text = text.replace(/<style.*?>.*?<\/style>/gsi, '');
33
+
34
+ // Final Strip of remaining tags
35
+ text = text.replace(/<[^>]+>/g, ' ');
36
+
37
+ // Entity decoding (Basic)
38
+ text = text.replace(/&nbsp;/gi, ' ');
39
+ text = text.replace(/&amp;/gi, '&');
40
+ text = text.replace(/&lt;/gi, '<');
41
+ text = text.replace(/&gt;/gi, '>');
42
+ text = text.replace(/&quot;/gi, '"');
43
+ text = text.replace(/&#39;/gi, "'");
44
+
45
+ const lines = text.split('\n');
46
+ const cleanedLines: string[] = [];
47
+
48
+ // Heuristics for reply headers
49
+ const replyHeaderPatterns = [
50
+ /^On .* wrote:$/i,
51
+ /^From: .*$/i,
52
+ /^Sent: .*$/i,
53
+ /^To: .*$/i,
54
+ /^Subject: .*$/i
55
+ ];
56
+
57
+ // Heuristics for footers
58
+ const footerPatterns = [
59
+ /unsubscribe/i,
60
+ /privacy policy/i,
61
+ /terms of service/i,
62
+ /view in browser/i,
63
+ /copyright \d{4}/i
64
+ ];
65
+
66
+ for (let line of lines) {
67
+ let lineStripped = line.trim();
68
+
69
+ // 2. Quoted text removal (lines starting with >)
70
+ if (lineStripped.startsWith('>')) {
71
+ continue;
72
+ }
73
+
74
+ // 3. Check for specific reply separators
75
+ // If we hit a reply header, we truncate the rest (Aggressive strategy per Python code)
76
+ if (/^On .* wrote:$/i.test(lineStripped)) {
77
+ break;
78
+ }
79
+
80
+ // 4. Footer removal (simple check on short lines)
81
+ if (lineStripped.length < 100) {
82
+ let isFooter = false;
83
+ for (const pattern of footerPatterns) {
84
+ if (pattern.test(lineStripped)) {
85
+ isFooter = true;
86
+ break;
87
+ }
88
+ }
89
+ if (isFooter) {
90
+ continue;
91
+ }
92
+ }
93
+
94
+ cleanedLines.push(line);
95
+ }
96
+
97
+ // Reassemble
98
+ text = cleanedLines.join('\n');
99
+
100
+ // Collapse multiple newlines
101
+ text = text.replace(/\n{3,}/g, '\n\n');
102
+
103
+ // Sanitize LLM Special Tokens (Prevent Prompt Injection/Confusion)
104
+ // Break sequences like <|channel|>, [INST], <s>
105
+ text = text.replace(/<\|/g, '< |');
106
+ text = text.replace(/\|>/g, '| >');
107
+ text = text.replace(/\[INST\]/gi, '[ INST ]');
108
+ text = text.replace(/\[\/INST\]/gi, '[ /INST ]');
109
+ text = text.replace(/<s>/gi, '&lt;s&gt;');
110
+ text = text.replace(/<\/s>/gi, '&lt;/s&gt;');
111
+
112
+ return text.trim();
113
+ }
114
+ }
@@ -0,0 +1,80 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
2
+ import { config } from '../config/index.js';
3
+
4
+ // Edge Functions compatible encryption (matching supabase/functions/_shared/encryption.ts)
5
+ const ALGORITHM = 'aes-256-gcm';
6
+ const IV_LENGTH = 12; // Match Edge Functions
7
+ const KEY_LENGTH = 32;
8
+
9
+ function getKey(): Buffer {
10
+ const secret = config.security.encryptionKey || 'dev-key-not-secure';
11
+ // Match Edge Functions key derivation: pad/slice to 32 chars
12
+ return Buffer.from(secret.padEnd(32, '0').slice(0, 32), 'utf8');
13
+ }
14
+
15
+ export function encryptToken(plaintext: string): string {
16
+ if (!plaintext) return '';
17
+
18
+ const iv = randomBytes(IV_LENGTH);
19
+ const key = getKey();
20
+
21
+ const cipher = createCipheriv(ALGORITHM, key, iv);
22
+ const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
23
+ const tag = cipher.getAuthTag();
24
+
25
+ // Format: base64(iv + ciphertext + tag) - compatible with Edge Functions
26
+ const combined = Buffer.concat([iv, encrypted, tag]);
27
+ return combined.toString('base64');
28
+ }
29
+
30
+ export function decryptToken(encrypted: string): string {
31
+ if (!encrypted) return '';
32
+
33
+ try {
34
+ // Try Edge Functions format first: base64(iv + ciphertext + tag)
35
+ const combined = Buffer.from(encrypted, 'base64');
36
+
37
+ if (combined.length < IV_LENGTH + 16) {
38
+ // Too short, might be plaintext
39
+ return encrypted;
40
+ }
41
+
42
+ const iv = combined.subarray(0, IV_LENGTH);
43
+ const tag = combined.subarray(combined.length - 16);
44
+ const data = combined.subarray(IV_LENGTH, combined.length - 16);
45
+ const key = getKey();
46
+
47
+ const decipher = createDecipheriv(ALGORITHM, key, iv);
48
+ decipher.setAuthTag(tag);
49
+
50
+ return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
51
+ } catch (error) {
52
+ // Try legacy format: salt:iv:tag:encrypted
53
+ try {
54
+ const parts = encrypted.split(':');
55
+ if (parts.length === 4) {
56
+ const [saltB64, ivB64, tagB64, dataB64] = parts;
57
+ const salt = Buffer.from(saltB64, 'base64');
58
+ const iv = Buffer.from(ivB64, 'base64');
59
+ const tag = Buffer.from(tagB64, 'base64');
60
+ const data = Buffer.from(dataB64, 'base64');
61
+ const secret = config.security.encryptionKey || 'dev-key-not-secure';
62
+ const key = scryptSync(secret, salt, KEY_LENGTH);
63
+
64
+ const decipher = createDecipheriv(ALGORITHM, key, iv);
65
+ decipher.setAuthTag(tag);
66
+
67
+ return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
68
+ }
69
+ } catch {
70
+ // Fall through to plaintext
71
+ }
72
+
73
+ // If all decryption fails, assume plaintext (for migration)
74
+ return encrypted;
75
+ }
76
+ }
77
+
78
+ export function generateSecureToken(length: number = 32): string {
79
+ return randomBytes(length).toString('hex');
80
+ }