@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
package/api/server.ts CHANGED
@@ -66,8 +66,10 @@ app.use('/api', apiRateLimit);
66
66
  // API routes
67
67
  app.use('/api', routes);
68
68
 
69
- // Serve static files in production or if dist exists
70
- const distPath = path.join(__dirname, '..', 'dist');
69
+ // Serve static files - robust resolution for compiled app
70
+ // In npx/dist mode, dist is at ../../../dist relative to this file
71
+ // In dev mode, it is at ../dist
72
+ const distPath = path.join(process.cwd(), 'dist');
71
73
  app.use(express.static(distPath));
72
74
 
73
75
  // Handle client-side routing
@@ -109,12 +111,6 @@ const server = app.listen(config.port, () => {
109
111
  if (getServerSupabase()) {
110
112
  startScheduler();
111
113
  }
112
-
113
- // Automatically open browser unless -n flag is provided
114
- if (!config.noUi) {
115
- const startCommand = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
116
- spawn(startCommand, [url], { detached: true }).unref();
117
- }
118
114
  });
119
115
 
120
116
  // Handle server errors
@@ -2,6 +2,9 @@ import dotenv from 'dotenv';
2
2
  import { fileURLToPath } from 'url';
3
3
  import { dirname, join } from 'path';
4
4
 
5
+ // 1. Try to load from current working directory (e.g. where npx is run)
6
+ dotenv.config({ path: join(process.cwd(), '.env') });
7
+ // 2. Fallback to package root
5
8
  dotenv.config();
6
9
 
7
10
  const __filename = fileURLToPath(import.meta.url);
@@ -32,9 +35,9 @@ export const config = {
32
35
  nodeEnv: process.env.NODE_ENV || 'development',
33
36
  isProduction: process.env.NODE_ENV === 'production',
34
37
 
35
- // Paths
36
- rootDir: join(__dirname, '..', '..', '..'),
37
- scriptsDir: join(__dirname, '..', '..', '..', 'scripts'),
38
+ // Paths - Robust resolution for both TS source and compiled JS in dist/
39
+ rootDir: process.cwd(),
40
+ scriptsDir: join(process.cwd(), 'scripts'),
38
41
 
39
42
  // Supabase
40
43
  supabase: {
@@ -30,9 +30,8 @@ async function setup() {
30
30
  console.log('========================');
31
31
  console.log('');
32
32
 
33
- const envPath = join(__dirname, '..', '.env');
34
- const envExamplePath = join(__dirname, '..', '.env.example');
35
-
33
+ const envPath = join(process.cwd(), '.env');
34
+
36
35
  // Check if .env already exists
37
36
  if (existsSync(envPath)) {
38
37
  const overwrite = await question('⚠️ .env file already exists. Overwrite? (y/N): ');
@@ -5,9 +5,10 @@
5
5
  * Main command to run the Email Automator API server
6
6
  */
7
7
 
8
- import { spawn } from 'child_process';
8
+ import { spawn, execSync } from 'child_process';
9
9
  import { fileURLToPath } from 'url';
10
10
  import { dirname, join } from 'path';
11
+ import { existsSync } from 'fs';
11
12
 
12
13
  const __filename = fileURLToPath(import.meta.url);
13
14
  const __dirname = dirname(__filename);
@@ -24,21 +25,16 @@ if (portIndex !== -1 && args[portIndex + 1]) {
24
25
 
25
26
  const noUi = args.includes('--no-ui');
26
27
 
27
- console.log('🚀 Starting Email Automator...');
28
+ console.log('🚀 Email Automator starting...');
28
29
  console.log(`📡 Port: ${port}`);
29
30
  if (noUi) console.log('🖥️ Mode: No-UI');
30
31
  console.log('');
31
32
 
32
- // Path to server
33
- const serverPath = join(__dirname, '..', 'api', 'server.ts');
33
+ // Path to compiled server
34
+ const serverPath = join(__dirname, '..', 'dist', 'api', 'server.js');
34
35
 
35
- // Resolve tsx binary path
36
- // In npx/installed mode, it will be in ../node_modules/.bin/tsx
37
- // In dev mode, it will be in ../node_modules/.bin/tsx
38
- const tsxPath = join(__dirname, '..', 'node_modules', '.bin', 'tsx');
39
-
40
- // Start server with resolved tsx
41
- const server = spawn(tsxPath, [serverPath, ...args], {
36
+ // Start server with standard node
37
+ const server = spawn(process.execPath, [serverPath, ...args], {
42
38
  stdio: 'inherit',
43
39
  env: { ...process.env, PORT: port },
44
40
  });
@@ -0,0 +1,109 @@
1
+ import express from 'express';
2
+ import cors from 'cors';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { config, validateConfig } from './src/config/index.js';
6
+ import { errorHandler } from './src/middleware/errorHandler.js';
7
+ import { apiRateLimit } from './src/middleware/rateLimit.js';
8
+ import routes from './src/routes/index.js';
9
+ import { logger } from './src/utils/logger.js';
10
+ import { getServerSupabase } from './src/services/supabase.js';
11
+ import { startScheduler, stopScheduler } from './src/services/scheduler.js';
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = path.dirname(__filename);
14
+ // Validate configuration
15
+ const configValidation = validateConfig();
16
+ if (!configValidation.valid) {
17
+ logger.warn('Configuration warnings', { errors: configValidation.errors });
18
+ }
19
+ const app = express();
20
+ // Security headers
21
+ app.use((req, res, next) => {
22
+ res.setHeader('X-Content-Type-Options', 'nosniff');
23
+ res.setHeader('X-Frame-Options', 'DENY');
24
+ res.setHeader('X-XSS-Protection', '1; mode=block');
25
+ if (config.isProduction) {
26
+ res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
27
+ }
28
+ next();
29
+ });
30
+ // CORS configuration
31
+ app.use(cors({
32
+ origin: config.isProduction
33
+ ? config.security.corsOrigins
34
+ : true, // Allow all in development
35
+ credentials: true,
36
+ methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
37
+ allowedHeaders: ['Content-Type', 'Authorization', 'X-Supabase-Url', 'X-Supabase-Anon-Key'],
38
+ }));
39
+ // Body parsing
40
+ app.use(express.json({ limit: '10mb' }));
41
+ app.use(express.urlencoded({ extended: true, limit: '10mb' }));
42
+ // Request logging
43
+ app.use((req, res, next) => {
44
+ const start = Date.now();
45
+ res.on('finish', () => {
46
+ const duration = Date.now() - start;
47
+ logger.debug(`${req.method} ${req.path}`, {
48
+ status: res.statusCode,
49
+ duration: `${duration}ms`,
50
+ });
51
+ });
52
+ next();
53
+ });
54
+ // Rate limiting (global)
55
+ app.use('/api', apiRateLimit);
56
+ // API routes
57
+ app.use('/api', routes);
58
+ // Serve static files - robust resolution for compiled app
59
+ // In npx/dist mode, dist is at ../../../dist relative to this file
60
+ // In dev mode, it is at ../dist
61
+ const distPath = path.join(process.cwd(), 'dist');
62
+ app.use(express.static(distPath));
63
+ // Handle client-side routing
64
+ app.get(/.*/, (req, res, next) => {
65
+ if (req.path.startsWith('/api'))
66
+ return next();
67
+ res.sendFile(path.join(distPath, 'index.html'), (err) => {
68
+ if (err) {
69
+ // If dist doesn't exist, return 404 for non-API routes
70
+ res.status(404).json({
71
+ success: false,
72
+ error: { code: 'NOT_FOUND', message: 'Frontend not built or endpoint not found' }
73
+ });
74
+ }
75
+ });
76
+ });
77
+ // Error handler (must be last)
78
+ app.use(errorHandler);
79
+ // Graceful shutdown
80
+ const shutdown = () => {
81
+ logger.info('Shutting down gracefully...');
82
+ stopScheduler();
83
+ process.exit(0);
84
+ };
85
+ process.on('SIGTERM', shutdown);
86
+ process.on('SIGINT', shutdown);
87
+ // Start server
88
+ const server = app.listen(config.port, () => {
89
+ const url = `http://localhost:${config.port}`;
90
+ logger.info(`Server running at ${url}`, {
91
+ environment: config.nodeEnv,
92
+ supabase: getServerSupabase() ? 'connected' : 'not configured',
93
+ });
94
+ // Start background scheduler
95
+ if (getServerSupabase()) {
96
+ startScheduler();
97
+ }
98
+ });
99
+ // Handle server errors
100
+ server.on('error', (error) => {
101
+ if (error.code === 'EADDRINUSE') {
102
+ logger.error(`Port ${config.port} is already in use`);
103
+ }
104
+ else {
105
+ logger.error('Server error', error);
106
+ }
107
+ process.exit(1);
108
+ });
109
+ export default app;
@@ -0,0 +1,88 @@
1
+ import dotenv from 'dotenv';
2
+ import { fileURLToPath } from 'url';
3
+ import { dirname, join } from 'path';
4
+ // 1. Try to load from current working directory (e.g. where npx is run)
5
+ dotenv.config({ path: join(process.cwd(), '.env') });
6
+ // 2. Fallback to package root
7
+ dotenv.config();
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+ function parseArgs(args) {
11
+ const portIndex = args.indexOf('--port');
12
+ let port = null;
13
+ if (portIndex !== -1 && args[portIndex + 1]) {
14
+ const p = parseInt(args[portIndex + 1], 10);
15
+ if (!isNaN(p) && p > 0 && p < 65536) {
16
+ port = p;
17
+ }
18
+ }
19
+ const noUi = args.includes('--no-ui');
20
+ return { port, noUi };
21
+ }
22
+ const cliArgs = parseArgs(process.argv.slice(2));
23
+ export const config = {
24
+ // Server
25
+ // Default port 3004 (RealTimeX Desktop uses 3001/3002)
26
+ port: cliArgs.port || (process.env.PORT ? parseInt(process.env.PORT, 10) : 3004),
27
+ noUi: cliArgs.noUi,
28
+ nodeEnv: process.env.NODE_ENV || 'development',
29
+ isProduction: process.env.NODE_ENV === 'production',
30
+ // Paths - Robust resolution for both TS source and compiled JS in dist/
31
+ rootDir: process.cwd(),
32
+ scriptsDir: join(process.cwd(), 'scripts'),
33
+ // Supabase
34
+ supabase: {
35
+ url: process.env.SUPABASE_URL || process.env.VITE_SUPABASE_URL || '',
36
+ anonKey: process.env.SUPABASE_ANON_KEY || process.env.VITE_SUPABASE_ANON_KEY || '',
37
+ serviceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY || '',
38
+ },
39
+ // LLM
40
+ llm: {
41
+ apiKey: process.env.LLM_API_KEY || '',
42
+ baseUrl: process.env.LLM_BASE_URL,
43
+ model: process.env.LLM_MODEL || 'gpt-4o-mini',
44
+ },
45
+ // OAuth - Gmail
46
+ gmail: {
47
+ clientId: process.env.GMAIL_CLIENT_ID || '',
48
+ clientSecret: process.env.GMAIL_CLIENT_SECRET || '',
49
+ redirectUri: process.env.GMAIL_REDIRECT_URI || 'urn:ietf:wg:oauth:2.0:oob',
50
+ },
51
+ // OAuth - Microsoft
52
+ microsoft: {
53
+ clientId: process.env.MS_GRAPH_CLIENT_ID || '',
54
+ tenantId: process.env.MS_GRAPH_TENANT_ID || 'common',
55
+ clientSecret: process.env.MS_GRAPH_CLIENT_SECRET || '',
56
+ },
57
+ // Security
58
+ security: {
59
+ encryptionKey: process.env.TOKEN_ENCRYPTION_KEY || '',
60
+ jwtSecret: process.env.JWT_SECRET || 'dev-secret-change-in-production',
61
+ corsOrigins: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:3003', 'http://localhost:5173'],
62
+ rateLimitWindowMs: 15 * 60 * 1000, // 15 minutes
63
+ rateLimitMax: 100,
64
+ disableAuth: process.env.DISABLE_AUTH === 'true',
65
+ },
66
+ // Processing
67
+ processing: {
68
+ batchSize: parseInt(process.env.EMAIL_BATCH_SIZE || '20', 10),
69
+ syncIntervalMs: parseInt(process.env.SYNC_INTERVAL_MS || '60000', 10), // 1 minute
70
+ maxRetries: 3,
71
+ },
72
+ };
73
+ export function validateConfig() {
74
+ const errors = [];
75
+ if (!config.supabase.url) {
76
+ errors.push('SUPABASE_URL is required');
77
+ }
78
+ if (!config.supabase.anonKey) {
79
+ errors.push('SUPABASE_ANON_KEY is required');
80
+ }
81
+ if (config.isProduction && config.security.jwtSecret === 'dev-secret-change-in-production') {
82
+ errors.push('JWT_SECRET must be set in production');
83
+ }
84
+ if (config.isProduction && !config.security.encryptionKey) {
85
+ errors.push('TOKEN_ENCRYPTION_KEY must be set in production');
86
+ }
87
+ return { valid: errors.length === 0, errors };
88
+ }
@@ -0,0 +1,119 @@
1
+ import { createClient } from '@supabase/supabase-js';
2
+ import { config } from '../config/index.js';
3
+ import { AuthenticationError, AuthorizationError } from './errorHandler.js';
4
+ import { createLogger, Logger } from '../utils/logger.js';
5
+ const logger = createLogger('AuthMiddleware');
6
+ import { getServerSupabase, isValidUrl } from '../services/supabase.js';
7
+ // Check if anon key looks valid (JWT or publishable key format)
8
+ function isValidAnonKey(key) {
9
+ if (!key)
10
+ return false;
11
+ // JWT anon keys start with eyJ, publishable keys start with sb_publishable_
12
+ return key.startsWith('eyJ') || key.startsWith('sb_publishable_');
13
+ }
14
+ // Helper to get Supabase config from request headers (frontend passes these)
15
+ function getSupabaseConfigFromRequest(req) {
16
+ const url = req.headers['x-supabase-url'];
17
+ const anonKey = req.headers['x-supabase-anon-key'];
18
+ if (url && anonKey && isValidUrl(url) && isValidAnonKey(anonKey)) {
19
+ return { url, anonKey };
20
+ }
21
+ return null;
22
+ }
23
+ export async function authMiddleware(req, _res, next) {
24
+ try {
25
+ // Get Supabase config: prefer env vars, fallback to request headers
26
+ const headerConfig = getSupabaseConfigFromRequest(req);
27
+ const envUrl = config.supabase.url;
28
+ const envKey = config.supabase.anonKey;
29
+ // Basic validation: URL must start with http(s)
30
+ // This prevents using placeholders like "CHANGE_ME" or empty strings
31
+ const isEnvUrlValid = envUrl && (envUrl.startsWith('http://') || envUrl.startsWith('https://'));
32
+ const isEnvKeyValid = !!envKey && envKey.length > 0;
33
+ const supabaseUrl = isEnvUrlValid ? envUrl : (headerConfig?.url || '');
34
+ const supabaseAnonKey = isEnvKeyValid ? envKey : (headerConfig?.anonKey || '');
35
+ // Development bypass: skip auth if DISABLE_AUTH=true in non-production
36
+ if (config.security.disableAuth && !config.isProduction) {
37
+ logger.warn('Auth disabled for development - creating mock user');
38
+ // Create a mock user for development
39
+ req.user = {
40
+ id: '00000000-0000-0000-0000-000000000000',
41
+ email: 'dev@local.test',
42
+ user_metadata: {},
43
+ app_metadata: {},
44
+ aud: 'authenticated',
45
+ created_at: new Date().toISOString(),
46
+ };
47
+ // Use the shared Supabase client, or create one from request headers
48
+ let supabase = getServerSupabase();
49
+ if (!supabase && supabaseUrl && supabaseAnonKey) {
50
+ supabase = createClient(supabaseUrl, supabaseAnonKey, {
51
+ auth: { autoRefreshToken: false, persistSession: false },
52
+ });
53
+ }
54
+ if (supabase) {
55
+ req.supabase = supabase;
56
+ // Initialize logger persistence for mock user
57
+ Logger.setPersistence(supabase, req.user.id);
58
+ }
59
+ else {
60
+ throw new AuthenticationError('Supabase not configured. Please set up Supabase in the app or provide SUPABASE_URL/ANON_KEY in .env');
61
+ }
62
+ return next();
63
+ }
64
+ const authHeader = req.headers.authorization;
65
+ if (!authHeader?.startsWith('Bearer ')) {
66
+ throw new AuthenticationError('Missing or invalid authorization header');
67
+ }
68
+ const token = authHeader.substring(7);
69
+ if (!supabaseUrl || !supabaseAnonKey) {
70
+ throw new AuthenticationError('Supabase not configured. Please set up Supabase in the app or provide SUPABASE_URL/ANON_KEY in .env');
71
+ }
72
+ // Create a Supabase client with the user's token
73
+ const supabase = createClient(supabaseUrl, supabaseAnonKey, {
74
+ global: {
75
+ headers: {
76
+ Authorization: `Bearer ${token}`,
77
+ },
78
+ },
79
+ });
80
+ // Verify the token by getting the user
81
+ const { data: { user }, error } = await supabase.auth.getUser(token);
82
+ if (error || !user) {
83
+ logger.debug('Auth failed', { error: error?.message });
84
+ throw new AuthenticationError('Invalid or expired token');
85
+ }
86
+ // Initialize logger persistence for this request
87
+ Logger.setPersistence(supabase, user.id);
88
+ // Attach user and supabase client to request
89
+ req.user = user;
90
+ req.supabase = supabase;
91
+ next();
92
+ }
93
+ catch (error) {
94
+ logger.error('Auth middleware error', error);
95
+ next(error);
96
+ }
97
+ }
98
+ export function optionalAuth(req, _res, next) {
99
+ const authHeader = req.headers.authorization;
100
+ if (!authHeader?.startsWith('Bearer ')) {
101
+ // No auth provided, continue without user
102
+ return next();
103
+ }
104
+ // If auth is provided, validate it
105
+ authMiddleware(req, _res, next);
106
+ }
107
+ export function requireRole(roles) {
108
+ return async (req, _res, next) => {
109
+ if (!req.user) {
110
+ return next(new AuthenticationError());
111
+ }
112
+ // Check user metadata for role (customize based on your auth setup)
113
+ const userRole = req.user.user_metadata?.role || 'user';
114
+ if (!roles.includes(userRole)) {
115
+ return next(new AuthorizationError(`Requires one of: ${roles.join(', ')}`));
116
+ }
117
+ next();
118
+ };
119
+ }
@@ -0,0 +1,78 @@
1
+ import { createLogger } from '../utils/logger.js';
2
+ import { config } from '../config/index.js';
3
+ const logger = createLogger('ErrorHandler');
4
+ export class AppError extends Error {
5
+ statusCode;
6
+ isOperational;
7
+ code;
8
+ constructor(message, statusCode = 500, code) {
9
+ super(message);
10
+ this.statusCode = statusCode;
11
+ this.isOperational = true;
12
+ this.code = code;
13
+ Error.captureStackTrace(this, this.constructor);
14
+ }
15
+ }
16
+ export class ValidationError extends AppError {
17
+ constructor(message) {
18
+ super(message, 400, 'VALIDATION_ERROR');
19
+ }
20
+ }
21
+ export class AuthenticationError extends AppError {
22
+ constructor(message = 'Authentication required') {
23
+ super(message, 401, 'AUTHENTICATION_ERROR');
24
+ }
25
+ }
26
+ export class AuthorizationError extends AppError {
27
+ constructor(message = 'Insufficient permissions') {
28
+ super(message, 403, 'AUTHORIZATION_ERROR');
29
+ }
30
+ }
31
+ export class NotFoundError extends AppError {
32
+ constructor(resource = 'Resource') {
33
+ super(`${resource} not found`, 404, 'NOT_FOUND');
34
+ }
35
+ }
36
+ export class RateLimitError extends AppError {
37
+ constructor() {
38
+ super('Too many requests, please try again later', 429, 'RATE_LIMIT_EXCEEDED');
39
+ }
40
+ }
41
+ export function errorHandler(err, req, res, _next) {
42
+ // Default to 500 if not an AppError
43
+ const statusCode = err instanceof AppError ? err.statusCode : 500;
44
+ const isOperational = err instanceof AppError ? err.isOperational : false;
45
+ const code = err instanceof AppError ? err.code : 'INTERNAL_ERROR';
46
+ // Log error
47
+ if (statusCode >= 500) {
48
+ logger.error('Server error', err, {
49
+ method: req.method,
50
+ path: req.path,
51
+ statusCode,
52
+ });
53
+ }
54
+ else {
55
+ logger.warn('Client error', {
56
+ method: req.method,
57
+ path: req.path,
58
+ statusCode,
59
+ message: err.message,
60
+ });
61
+ }
62
+ // Send response
63
+ res.status(statusCode).json({
64
+ success: false,
65
+ error: {
66
+ code,
67
+ message: isOperational || !config.isProduction
68
+ ? err.message
69
+ : 'An unexpected error occurred',
70
+ ...(config.isProduction ? {} : { stack: err.stack }),
71
+ },
72
+ });
73
+ }
74
+ export function asyncHandler(fn) {
75
+ return (req, res, next) => {
76
+ Promise.resolve(fn(req, res, next)).catch(next);
77
+ };
78
+ }
@@ -0,0 +1,4 @@
1
+ export { errorHandler, asyncHandler, AppError, ValidationError, AuthenticationError, AuthorizationError, NotFoundError, RateLimitError } from './errorHandler.js';
2
+ export { authMiddleware, optionalAuth, requireRole } from './auth.js';
3
+ export { rateLimit, authRateLimit, apiRateLimit, syncRateLimit } from './rateLimit.js';
4
+ export { validateBody, validateQuery, validateParams, schemas } from './validation.js';
@@ -0,0 +1,57 @@
1
+ import { RateLimitError } from './errorHandler.js';
2
+ import { config } from '../config/index.js';
3
+ // In-memory store (use Redis in production for multi-instance deployments)
4
+ const rateLimitStore = new Map();
5
+ // Cleanup old entries periodically
6
+ setInterval(() => {
7
+ const now = Date.now();
8
+ for (const [key, entry] of rateLimitStore.entries()) {
9
+ if (entry.resetAt < now) {
10
+ rateLimitStore.delete(key);
11
+ }
12
+ }
13
+ }, 60000); // Cleanup every minute
14
+ export function rateLimit(options = {}) {
15
+ const { windowMs = config.security.rateLimitWindowMs, max = config.security.rateLimitMax, keyGenerator = (req) => req.ip || req.headers['x-forwarded-for']?.toString() || 'unknown', skip = () => false, } = options;
16
+ return (req, res, next) => {
17
+ if (skip(req)) {
18
+ return next();
19
+ }
20
+ const key = keyGenerator(req);
21
+ const now = Date.now();
22
+ let entry = rateLimitStore.get(key);
23
+ if (!entry || entry.resetAt < now) {
24
+ entry = {
25
+ count: 1,
26
+ resetAt: now + windowMs,
27
+ };
28
+ rateLimitStore.set(key, entry);
29
+ }
30
+ else {
31
+ entry.count++;
32
+ }
33
+ // Set rate limit headers
34
+ res.setHeader('X-RateLimit-Limit', max);
35
+ res.setHeader('X-RateLimit-Remaining', Math.max(0, max - entry.count));
36
+ res.setHeader('X-RateLimit-Reset', Math.ceil(entry.resetAt / 1000));
37
+ if (entry.count > max) {
38
+ return next(new RateLimitError());
39
+ }
40
+ next();
41
+ };
42
+ }
43
+ // Stricter rate limit for auth endpoints
44
+ export const authRateLimit = rateLimit({
45
+ windowMs: 15 * 60 * 1000, // 15 minutes
46
+ max: 10, // 10 attempts per 15 minutes
47
+ });
48
+ // Standard API rate limit
49
+ export const apiRateLimit = rateLimit({
50
+ windowMs: 60 * 1000, // 1 minute
51
+ max: 60, // 60 requests per minute
52
+ });
53
+ // Sync rate limit (expensive operation)
54
+ export const syncRateLimit = rateLimit({
55
+ windowMs: 60 * 1000, // 1 minute
56
+ max: 5, // 5 sync requests per minute
57
+ });
@@ -0,0 +1,111 @@
1
+ import { z, ZodError } from 'zod';
2
+ import { ValidationError } from './errorHandler.js';
3
+ export function validateBody(schema) {
4
+ return (req, _res, next) => {
5
+ try {
6
+ req.body = schema.parse(req.body);
7
+ next();
8
+ }
9
+ catch (error) {
10
+ if (error instanceof ZodError) {
11
+ const messages = error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
12
+ next(new ValidationError(messages));
13
+ }
14
+ else {
15
+ next(error);
16
+ }
17
+ }
18
+ };
19
+ }
20
+ export function validateQuery(schema) {
21
+ return (req, _res, next) => {
22
+ try {
23
+ req.query = schema.parse(req.query);
24
+ next();
25
+ }
26
+ catch (error) {
27
+ if (error instanceof ZodError) {
28
+ const messages = error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
29
+ next(new ValidationError(messages));
30
+ }
31
+ else {
32
+ next(error);
33
+ }
34
+ }
35
+ };
36
+ }
37
+ export function validateParams(schema) {
38
+ return (req, _res, next) => {
39
+ try {
40
+ req.params = schema.parse(req.params);
41
+ next();
42
+ }
43
+ catch (error) {
44
+ if (error instanceof ZodError) {
45
+ const messages = error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
46
+ next(new ValidationError(messages));
47
+ }
48
+ else {
49
+ next(error);
50
+ }
51
+ }
52
+ };
53
+ }
54
+ // Common validation schemas
55
+ export const schemas = {
56
+ uuid: z.string().uuid(),
57
+ email: z.string().email(),
58
+ // Auth schemas
59
+ gmailCallback: z.object({
60
+ code: z.string().min(1, 'Authorization code is required'),
61
+ }),
62
+ deviceFlow: z.object({
63
+ device_code: z.string().min(1, 'Device code is required'),
64
+ }),
65
+ // Sync schemas
66
+ syncRequest: z.object({
67
+ accountId: z.string().uuid('Invalid account ID'),
68
+ }),
69
+ // Action schemas
70
+ executeAction: z.object({
71
+ emailId: z.string().uuid('Invalid email ID'),
72
+ action: z.enum(['delete', 'archive', 'draft', 'flag', 'none']),
73
+ draftContent: z.string().optional(),
74
+ }),
75
+ // Migration schemas
76
+ migrate: z.object({
77
+ projectRef: z.string().min(1, 'Project reference is required'),
78
+ dbPassword: z.string().optional(),
79
+ accessToken: z.string().optional(),
80
+ }),
81
+ // Rule schemas
82
+ createRule: z.object({
83
+ name: z.string().min(1).max(100),
84
+ condition: z.record(z.unknown()),
85
+ action: z.enum(['delete', 'archive', 'draft', 'star', 'read']),
86
+ instructions: z.string().optional(),
87
+ is_enabled: z.boolean().default(true),
88
+ }),
89
+ updateRule: z.object({
90
+ name: z.string().min(1).max(100).optional(),
91
+ condition: z.record(z.unknown()).optional(),
92
+ action: z.enum(['delete', 'archive', 'draft', 'star', 'read']).optional(),
93
+ instructions: z.string().optional(),
94
+ is_enabled: z.boolean().optional(),
95
+ }),
96
+ // Settings schemas
97
+ updateSettings: z.object({
98
+ llm_model: z.string().optional(),
99
+ llm_base_url: z.string().url().optional().or(z.literal('')),
100
+ llm_api_key: z.string().optional(),
101
+ auto_trash_spam: z.boolean().optional(),
102
+ smart_drafts: z.boolean().optional(),
103
+ sync_interval_minutes: z.number().min(1).max(60).optional(),
104
+ // BYOK Credentials (transient, moved to integrations)
105
+ google_client_id: z.string().optional(),
106
+ google_client_secret: z.string().optional(),
107
+ microsoft_client_id: z.string().optional(),
108
+ microsoft_client_secret: z.string().optional(),
109
+ microsoft_tenant_id: z.string().optional(),
110
+ }),
111
+ };