@realtimex/email-automator 2.2.1 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/server.ts +4 -2
- package/api/src/config/index.ts +11 -9
- package/bin/email-automator.js +4 -24
- package/dist/api/server.js +109 -0
- package/dist/api/src/config/index.js +89 -0
- package/dist/api/src/middleware/auth.js +119 -0
- package/dist/api/src/middleware/errorHandler.js +78 -0
- package/dist/api/src/middleware/index.js +4 -0
- package/dist/api/src/middleware/rateLimit.js +57 -0
- package/dist/api/src/middleware/validation.js +111 -0
- package/dist/api/src/routes/actions.js +173 -0
- package/dist/api/src/routes/auth.js +106 -0
- package/dist/api/src/routes/emails.js +100 -0
- package/dist/api/src/routes/health.js +33 -0
- package/dist/api/src/routes/index.js +19 -0
- package/dist/api/src/routes/migrate.js +61 -0
- package/dist/api/src/routes/rules.js +104 -0
- package/dist/api/src/routes/settings.js +178 -0
- package/dist/api/src/routes/sync.js +118 -0
- package/dist/api/src/services/eventLogger.js +41 -0
- package/dist/api/src/services/gmail.js +350 -0
- package/dist/api/src/services/intelligence.js +243 -0
- package/dist/api/src/services/microsoft.js +256 -0
- package/dist/api/src/services/processor.js +503 -0
- package/dist/api/src/services/scheduler.js +210 -0
- package/dist/api/src/services/supabase.js +59 -0
- package/dist/api/src/utils/contentCleaner.js +94 -0
- package/dist/api/src/utils/crypto.js +68 -0
- package/dist/api/src/utils/logger.js +119 -0
- package/package.json +5 -4
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
|
|
70
|
-
|
|
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
|
package/api/src/config/index.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import dotenv from 'dotenv';
|
|
2
2
|
import { fileURLToPath } from 'url';
|
|
3
|
-
import { dirname, join } from 'path';
|
|
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
|
|
8
|
-
dotenv.config();
|
|
3
|
+
import path, { dirname, join } from 'path';
|
|
9
4
|
|
|
10
5
|
const __filename = fileURLToPath(import.meta.url);
|
|
11
6
|
const __dirname = dirname(__filename);
|
|
12
7
|
|
|
8
|
+
// 1. Try to load from current working directory (where npx is run)
|
|
9
|
+
dotenv.config({ path: join(process.cwd(), '.env') });
|
|
10
|
+
|
|
11
|
+
// 2. Fallback to package root (where the binary lives)
|
|
12
|
+
// In dist/api/src/config/index.js, the root is 4 levels up
|
|
13
|
+
dotenv.config({ path: join(__dirname, '..', '..', '..', '.env') });
|
|
14
|
+
|
|
13
15
|
function parseArgs(args: string[]): { port: number | null, noUi: boolean } {
|
|
14
16
|
const portIndex = args.indexOf('--port');
|
|
15
17
|
let port = null;
|
|
@@ -35,9 +37,9 @@ export const config = {
|
|
|
35
37
|
nodeEnv: process.env.NODE_ENV || 'development',
|
|
36
38
|
isProduction: process.env.NODE_ENV === 'production',
|
|
37
39
|
|
|
38
|
-
// Paths
|
|
39
|
-
rootDir:
|
|
40
|
-
scriptsDir: join(
|
|
40
|
+
// Paths - Robust resolution for both TS source and compiled JS in dist/
|
|
41
|
+
rootDir: process.cwd(),
|
|
42
|
+
scriptsDir: join(process.cwd(), 'scripts'),
|
|
41
43
|
|
|
42
44
|
// Supabase
|
|
43
45
|
supabase: {
|
package/bin/email-automator.js
CHANGED
|
@@ -30,31 +30,11 @@ console.log(`📡 Port: ${port}`);
|
|
|
30
30
|
if (noUi) console.log('🖥️ Mode: No-UI');
|
|
31
31
|
console.log('');
|
|
32
32
|
|
|
33
|
-
// Path to server
|
|
34
|
-
const serverPath = join(__dirname, '..', 'api', 'server.
|
|
33
|
+
// Path to compiled server
|
|
34
|
+
const serverPath = join(__dirname, '..', 'dist', 'api', 'server.js');
|
|
35
35
|
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
// 1. Try local node_modules
|
|
39
|
-
const localTsx = join(__dirname, '..', 'node_modules', '.bin', 'tsx');
|
|
40
|
-
if (existsSync(localTsx)) return localTsx;
|
|
41
|
-
|
|
42
|
-
// 2. Try to find in PATH
|
|
43
|
-
try {
|
|
44
|
-
const pathTsx = execSync(process.platform === 'win32' ? 'where tsx' : 'which tsx').toString().trim().split('\n')[0];
|
|
45
|
-
if (pathTsx) return pathTsx;
|
|
46
|
-
} catch (e) {
|
|
47
|
-
// which failed
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// 3. Fallback to just 'tsx' and hope for the best
|
|
51
|
-
return 'tsx';
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const tsxPath = getTsxPath();
|
|
55
|
-
|
|
56
|
-
// Start server
|
|
57
|
-
const server = spawn(tsxPath, [serverPath, ...args], {
|
|
36
|
+
// Start server with standard node
|
|
37
|
+
const server = spawn(process.execPath, [serverPath, ...args], {
|
|
58
38
|
stdio: 'inherit',
|
|
59
39
|
env: { ...process.env, PORT: port },
|
|
60
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,89 @@
|
|
|
1
|
+
import dotenv from 'dotenv';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import { dirname, join } from 'path';
|
|
4
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
5
|
+
const __dirname = dirname(__filename);
|
|
6
|
+
// 1. Try to load from current working directory (where npx is run)
|
|
7
|
+
dotenv.config({ path: join(process.cwd(), '.env') });
|
|
8
|
+
// 2. Fallback to package root (where the binary lives)
|
|
9
|
+
// In dist/api/src/config/index.js, the root is 4 levels up
|
|
10
|
+
dotenv.config({ path: join(__dirname, '..', '..', '..', '.env') });
|
|
11
|
+
function parseArgs(args) {
|
|
12
|
+
const portIndex = args.indexOf('--port');
|
|
13
|
+
let port = null;
|
|
14
|
+
if (portIndex !== -1 && args[portIndex + 1]) {
|
|
15
|
+
const p = parseInt(args[portIndex + 1], 10);
|
|
16
|
+
if (!isNaN(p) && p > 0 && p < 65536) {
|
|
17
|
+
port = p;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const noUi = args.includes('--no-ui');
|
|
21
|
+
return { port, noUi };
|
|
22
|
+
}
|
|
23
|
+
const cliArgs = parseArgs(process.argv.slice(2));
|
|
24
|
+
export const config = {
|
|
25
|
+
// Server
|
|
26
|
+
// Default port 3004 (RealTimeX Desktop uses 3001/3002)
|
|
27
|
+
port: cliArgs.port || (process.env.PORT ? parseInt(process.env.PORT, 10) : 3004),
|
|
28
|
+
noUi: cliArgs.noUi,
|
|
29
|
+
nodeEnv: process.env.NODE_ENV || 'development',
|
|
30
|
+
isProduction: process.env.NODE_ENV === 'production',
|
|
31
|
+
// Paths - Robust resolution for both TS source and compiled JS in dist/
|
|
32
|
+
rootDir: process.cwd(),
|
|
33
|
+
scriptsDir: join(process.cwd(), 'scripts'),
|
|
34
|
+
// Supabase
|
|
35
|
+
supabase: {
|
|
36
|
+
url: process.env.SUPABASE_URL || process.env.VITE_SUPABASE_URL || '',
|
|
37
|
+
anonKey: process.env.SUPABASE_ANON_KEY || process.env.VITE_SUPABASE_ANON_KEY || '',
|
|
38
|
+
serviceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY || '',
|
|
39
|
+
},
|
|
40
|
+
// LLM
|
|
41
|
+
llm: {
|
|
42
|
+
apiKey: process.env.LLM_API_KEY || '',
|
|
43
|
+
baseUrl: process.env.LLM_BASE_URL,
|
|
44
|
+
model: process.env.LLM_MODEL || 'gpt-4o-mini',
|
|
45
|
+
},
|
|
46
|
+
// OAuth - Gmail
|
|
47
|
+
gmail: {
|
|
48
|
+
clientId: process.env.GMAIL_CLIENT_ID || '',
|
|
49
|
+
clientSecret: process.env.GMAIL_CLIENT_SECRET || '',
|
|
50
|
+
redirectUri: process.env.GMAIL_REDIRECT_URI || 'urn:ietf:wg:oauth:2.0:oob',
|
|
51
|
+
},
|
|
52
|
+
// OAuth - Microsoft
|
|
53
|
+
microsoft: {
|
|
54
|
+
clientId: process.env.MS_GRAPH_CLIENT_ID || '',
|
|
55
|
+
tenantId: process.env.MS_GRAPH_TENANT_ID || 'common',
|
|
56
|
+
clientSecret: process.env.MS_GRAPH_CLIENT_SECRET || '',
|
|
57
|
+
},
|
|
58
|
+
// Security
|
|
59
|
+
security: {
|
|
60
|
+
encryptionKey: process.env.TOKEN_ENCRYPTION_KEY || '',
|
|
61
|
+
jwtSecret: process.env.JWT_SECRET || 'dev-secret-change-in-production',
|
|
62
|
+
corsOrigins: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:3003', 'http://localhost:5173'],
|
|
63
|
+
rateLimitWindowMs: 15 * 60 * 1000, // 15 minutes
|
|
64
|
+
rateLimitMax: 100,
|
|
65
|
+
disableAuth: process.env.DISABLE_AUTH === 'true',
|
|
66
|
+
},
|
|
67
|
+
// Processing
|
|
68
|
+
processing: {
|
|
69
|
+
batchSize: parseInt(process.env.EMAIL_BATCH_SIZE || '20', 10),
|
|
70
|
+
syncIntervalMs: parseInt(process.env.SYNC_INTERVAL_MS || '60000', 10), // 1 minute
|
|
71
|
+
maxRetries: 3,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
export function validateConfig() {
|
|
75
|
+
const errors = [];
|
|
76
|
+
if (!config.supabase.url) {
|
|
77
|
+
errors.push('SUPABASE_URL is required');
|
|
78
|
+
}
|
|
79
|
+
if (!config.supabase.anonKey) {
|
|
80
|
+
errors.push('SUPABASE_ANON_KEY is required');
|
|
81
|
+
}
|
|
82
|
+
if (config.isProduction && config.security.jwtSecret === 'dev-secret-change-in-production') {
|
|
83
|
+
errors.push('JWT_SECRET must be set in production');
|
|
84
|
+
}
|
|
85
|
+
if (config.isProduction && !config.security.encryptionKey) {
|
|
86
|
+
errors.push('TOKEN_ENCRYPTION_KEY must be set in production');
|
|
87
|
+
}
|
|
88
|
+
return { valid: errors.length === 0, errors };
|
|
89
|
+
}
|
|
@@ -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
|
+
};
|