@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.
- package/.env.example +35 -0
- package/LICENSE +21 -0
- package/README.md +247 -0
- package/api/server.ts +130 -0
- package/api/src/config/index.ts +102 -0
- package/api/src/middleware/auth.ts +166 -0
- package/api/src/middleware/errorHandler.ts +97 -0
- package/api/src/middleware/index.ts +4 -0
- package/api/src/middleware/rateLimit.ts +87 -0
- package/api/src/middleware/validation.ts +118 -0
- package/api/src/routes/actions.ts +214 -0
- package/api/src/routes/auth.ts +157 -0
- package/api/src/routes/emails.ts +144 -0
- package/api/src/routes/health.ts +36 -0
- package/api/src/routes/index.ts +22 -0
- package/api/src/routes/migrate.ts +76 -0
- package/api/src/routes/rules.ts +149 -0
- package/api/src/routes/settings.ts +229 -0
- package/api/src/routes/sync.ts +152 -0
- package/api/src/services/eventLogger.ts +52 -0
- package/api/src/services/gmail.ts +456 -0
- package/api/src/services/intelligence.ts +288 -0
- package/api/src/services/microsoft.ts +368 -0
- package/api/src/services/processor.ts +596 -0
- package/api/src/services/scheduler.ts +255 -0
- package/api/src/services/supabase.ts +144 -0
- package/api/src/utils/contentCleaner.ts +114 -0
- package/api/src/utils/crypto.ts +80 -0
- package/api/src/utils/logger.ts +142 -0
- package/bin/email-automator-deploy.js +79 -0
- package/bin/email-automator-setup.js +144 -0
- package/bin/email-automator.js +61 -0
- package/dist/assets/index-BQ1uMdFh.js +97 -0
- package/dist/assets/index-Dzi17fx5.css +1 -0
- package/dist/email-automator-logo.svg +51 -0
- package/dist/favicon.svg +45 -0
- package/dist/index.html +14 -0
- package/index.html +13 -0
- package/package.json +112 -0
- package/public/email-automator-logo.svg +51 -0
- package/public/favicon.svg +45 -0
- package/scripts/deploy-functions.sh +55 -0
- package/scripts/migrate.sh +177 -0
- package/src/App.tsx +622 -0
- package/src/components/AccountSettings.tsx +310 -0
- package/src/components/AccountSettingsPage.tsx +390 -0
- package/src/components/Configuration.tsx +1345 -0
- package/src/components/Dashboard.tsx +940 -0
- package/src/components/ErrorBoundary.tsx +71 -0
- package/src/components/LiveTerminal.tsx +308 -0
- package/src/components/LoadingSpinner.tsx +39 -0
- package/src/components/Login.tsx +371 -0
- package/src/components/Logo.tsx +57 -0
- package/src/components/SetupWizard.tsx +388 -0
- package/src/components/Toast.tsx +109 -0
- package/src/components/migration/MigrationBanner.tsx +97 -0
- package/src/components/migration/MigrationModal.tsx +458 -0
- package/src/components/migration/MigrationPulseIndicator.tsx +38 -0
- package/src/components/mode-toggle.tsx +24 -0
- package/src/components/theme-provider.tsx +72 -0
- package/src/components/ui/alert.tsx +66 -0
- package/src/components/ui/button.tsx +57 -0
- package/src/components/ui/card.tsx +75 -0
- package/src/components/ui/dialog.tsx +133 -0
- package/src/components/ui/input.tsx +22 -0
- package/src/components/ui/label.tsx +24 -0
- package/src/components/ui/otp-input.tsx +184 -0
- package/src/context/AppContext.tsx +422 -0
- package/src/context/MigrationContext.tsx +53 -0
- package/src/context/TerminalContext.tsx +31 -0
- package/src/core/actions.ts +76 -0
- package/src/core/auth.ts +108 -0
- package/src/core/intelligence.ts +76 -0
- package/src/core/processor.ts +112 -0
- package/src/hooks/useRealtimeEmails.ts +111 -0
- package/src/index.css +140 -0
- package/src/lib/api-config.ts +42 -0
- package/src/lib/api-old.ts +228 -0
- package/src/lib/api.ts +421 -0
- package/src/lib/migration-check.ts +264 -0
- package/src/lib/sounds.ts +120 -0
- package/src/lib/supabase-config.ts +117 -0
- package/src/lib/supabase.ts +28 -0
- package/src/lib/types.ts +166 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.tsx +10 -0
- package/supabase/.env.example +15 -0
- package/supabase/.temp/cli-latest +1 -0
- package/supabase/.temp/gotrue-version +1 -0
- package/supabase/.temp/pooler-url +1 -0
- package/supabase/.temp/postgres-version +1 -0
- package/supabase/.temp/project-ref +1 -0
- package/supabase/.temp/rest-version +1 -0
- package/supabase/.temp/storage-migration +1 -0
- package/supabase/.temp/storage-version +1 -0
- package/supabase/config.toml +95 -0
- package/supabase/functions/_shared/auth-helper.ts +76 -0
- package/supabase/functions/_shared/auth.ts +33 -0
- package/supabase/functions/_shared/cors.ts +45 -0
- package/supabase/functions/_shared/encryption.ts +70 -0
- package/supabase/functions/_shared/supabaseAdmin.ts +14 -0
- package/supabase/functions/api-v1-accounts/index.ts +133 -0
- package/supabase/functions/api-v1-emails/index.ts +177 -0
- package/supabase/functions/api-v1-rules/index.ts +177 -0
- package/supabase/functions/api-v1-settings/index.ts +247 -0
- package/supabase/functions/auth-gmail/index.ts +197 -0
- package/supabase/functions/auth-microsoft/index.ts +215 -0
- package/supabase/functions/setup/index.ts +92 -0
- package/supabase/migrations/20260114000000_initial_schema.sql +81 -0
- package/supabase/migrations/20260115000000_add_user_settings.sql +49 -0
- package/supabase/migrations/20260115000001_add_auth_flow.sql +80 -0
- package/supabase/migrations/20260115000002_fix_permissions.sql +5 -0
- package/supabase/migrations/20260115000003_fix_init_state_permissions.sql +9 -0
- package/supabase/migrations/20260115000004_add_migration_rpc.sql +13 -0
- package/supabase/migrations/20260115000005_add_provider_creds.sql +7 -0
- package/supabase/migrations/20260115000006_backfill_profiles.sql +22 -0
- package/supabase/migrations/20260116000000_add_sync_scope.sql +15 -0
- package/supabase/migrations/20260116000001_per_account_sync_scope.sql +19 -0
- package/supabase/migrations/20260116000002_add_llm_api_key.sql +5 -0
- package/supabase/migrations/20260117000000_refactor_integrations.sql +36 -0
- package/supabase/migrations/20260117000001_add_processing_events.sql +30 -0
- package/supabase/migrations/20260117000002_multi_actions.sql +15 -0
- package/supabase/migrations/20260117000003_seed_default_rules.sql +77 -0
- package/supabase/migrations/20260117000004_rule_instructions.sql +5 -0
- package/supabase/migrations/20260117000005_rule_attachments.sql +7 -0
- package/supabase/migrations/20260117000006_setup_storage.sql +32 -0
- package/supabase/migrations/20260117000007_add_system_logs.sql +26 -0
- package/supabase/migrations/20260117000008_link_logs_to_accounts.sql +8 -0
- package/supabase/migrations/20260117000009_convert_toggles_to_rules.sql +28 -0
- package/supabase/migrations/20260117000010_add_atomic_action_append.sql +13 -0
- package/supabase/migrations/20260117000011_add_profile_avatar.sql +4 -0
- package/supabase/migrations/20260117000012_setup_avatars_storage.sql +26 -0
- package/supabase/templates/confirmation.html +76 -0
- package/supabase/templates/email-change.html +76 -0
- package/supabase/templates/invite.html +72 -0
- package/supabase/templates/magic-link.html +68 -0
- package/supabase/templates/recovery.html +82 -0
- package/tsconfig.json +36 -0
- package/vite.config.ts +162 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import { createLogger } from '../utils/logger.js';
|
|
3
|
+
import { config } from '../config/index.js';
|
|
4
|
+
|
|
5
|
+
const logger = createLogger('ErrorHandler');
|
|
6
|
+
|
|
7
|
+
export class AppError extends Error {
|
|
8
|
+
statusCode: number;
|
|
9
|
+
isOperational: boolean;
|
|
10
|
+
code?: string;
|
|
11
|
+
|
|
12
|
+
constructor(message: string, statusCode: number = 500, code?: string) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.statusCode = statusCode;
|
|
15
|
+
this.isOperational = true;
|
|
16
|
+
this.code = code;
|
|
17
|
+
Error.captureStackTrace(this, this.constructor);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class ValidationError extends AppError {
|
|
22
|
+
constructor(message: string) {
|
|
23
|
+
super(message, 400, 'VALIDATION_ERROR');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class AuthenticationError extends AppError {
|
|
28
|
+
constructor(message: string = 'Authentication required') {
|
|
29
|
+
super(message, 401, 'AUTHENTICATION_ERROR');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class AuthorizationError extends AppError {
|
|
34
|
+
constructor(message: string = 'Insufficient permissions') {
|
|
35
|
+
super(message, 403, 'AUTHORIZATION_ERROR');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class NotFoundError extends AppError {
|
|
40
|
+
constructor(resource: string = 'Resource') {
|
|
41
|
+
super(`${resource} not found`, 404, 'NOT_FOUND');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class RateLimitError extends AppError {
|
|
46
|
+
constructor() {
|
|
47
|
+
super('Too many requests, please try again later', 429, 'RATE_LIMIT_EXCEEDED');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function errorHandler(
|
|
52
|
+
err: Error | AppError,
|
|
53
|
+
req: Request,
|
|
54
|
+
res: Response,
|
|
55
|
+
_next: NextFunction
|
|
56
|
+
): void {
|
|
57
|
+
// Default to 500 if not an AppError
|
|
58
|
+
const statusCode = err instanceof AppError ? err.statusCode : 500;
|
|
59
|
+
const isOperational = err instanceof AppError ? err.isOperational : false;
|
|
60
|
+
const code = err instanceof AppError ? err.code : 'INTERNAL_ERROR';
|
|
61
|
+
|
|
62
|
+
// Log error
|
|
63
|
+
if (statusCode >= 500) {
|
|
64
|
+
logger.error('Server error', err, {
|
|
65
|
+
method: req.method,
|
|
66
|
+
path: req.path,
|
|
67
|
+
statusCode,
|
|
68
|
+
});
|
|
69
|
+
} else {
|
|
70
|
+
logger.warn('Client error', {
|
|
71
|
+
method: req.method,
|
|
72
|
+
path: req.path,
|
|
73
|
+
statusCode,
|
|
74
|
+
message: err.message,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Send response
|
|
79
|
+
res.status(statusCode).json({
|
|
80
|
+
success: false,
|
|
81
|
+
error: {
|
|
82
|
+
code,
|
|
83
|
+
message: isOperational || !config.isProduction
|
|
84
|
+
? err.message
|
|
85
|
+
: 'An unexpected error occurred',
|
|
86
|
+
...(config.isProduction ? {} : { stack: err.stack }),
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function asyncHandler(
|
|
92
|
+
fn: (req: Request, res: Response, next: NextFunction) => Promise<any>
|
|
93
|
+
) {
|
|
94
|
+
return (req: Request, res: Response, next: NextFunction) => {
|
|
95
|
+
Promise.resolve(fn(req, res, next)).catch(next);
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -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,87 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import { RateLimitError } from './errorHandler.js';
|
|
3
|
+
import { config } from '../config/index.js';
|
|
4
|
+
|
|
5
|
+
interface RateLimitEntry {
|
|
6
|
+
count: number;
|
|
7
|
+
resetAt: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// In-memory store (use Redis in production for multi-instance deployments)
|
|
11
|
+
const rateLimitStore = new Map<string, RateLimitEntry>();
|
|
12
|
+
|
|
13
|
+
// Cleanup old entries periodically
|
|
14
|
+
setInterval(() => {
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
for (const [key, entry] of rateLimitStore.entries()) {
|
|
17
|
+
if (entry.resetAt < now) {
|
|
18
|
+
rateLimitStore.delete(key);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}, 60000); // Cleanup every minute
|
|
22
|
+
|
|
23
|
+
export interface RateLimitOptions {
|
|
24
|
+
windowMs?: number;
|
|
25
|
+
max?: number;
|
|
26
|
+
keyGenerator?: (req: Request) => string;
|
|
27
|
+
skip?: (req: Request) => boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function rateLimit(options: RateLimitOptions = {}) {
|
|
31
|
+
const {
|
|
32
|
+
windowMs = config.security.rateLimitWindowMs,
|
|
33
|
+
max = config.security.rateLimitMax,
|
|
34
|
+
keyGenerator = (req) => req.ip || req.headers['x-forwarded-for']?.toString() || 'unknown',
|
|
35
|
+
skip = () => false,
|
|
36
|
+
} = options;
|
|
37
|
+
|
|
38
|
+
return (req: Request, res: Response, next: NextFunction): void => {
|
|
39
|
+
if (skip(req)) {
|
|
40
|
+
return next();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const key = keyGenerator(req);
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
|
|
46
|
+
let entry = rateLimitStore.get(key);
|
|
47
|
+
|
|
48
|
+
if (!entry || entry.resetAt < now) {
|
|
49
|
+
entry = {
|
|
50
|
+
count: 1,
|
|
51
|
+
resetAt: now + windowMs,
|
|
52
|
+
};
|
|
53
|
+
rateLimitStore.set(key, entry);
|
|
54
|
+
} else {
|
|
55
|
+
entry.count++;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Set rate limit headers
|
|
59
|
+
res.setHeader('X-RateLimit-Limit', max);
|
|
60
|
+
res.setHeader('X-RateLimit-Remaining', Math.max(0, max - entry.count));
|
|
61
|
+
res.setHeader('X-RateLimit-Reset', Math.ceil(entry.resetAt / 1000));
|
|
62
|
+
|
|
63
|
+
if (entry.count > max) {
|
|
64
|
+
return next(new RateLimitError());
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
next();
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Stricter rate limit for auth endpoints
|
|
72
|
+
export const authRateLimit = rateLimit({
|
|
73
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
74
|
+
max: 10, // 10 attempts per 15 minutes
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Standard API rate limit
|
|
78
|
+
export const apiRateLimit = rateLimit({
|
|
79
|
+
windowMs: 60 * 1000, // 1 minute
|
|
80
|
+
max: 60, // 60 requests per minute
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Sync rate limit (expensive operation)
|
|
84
|
+
export const syncRateLimit = rateLimit({
|
|
85
|
+
windowMs: 60 * 1000, // 1 minute
|
|
86
|
+
max: 5, // 5 sync requests per minute
|
|
87
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import { z, ZodSchema, ZodError } from 'zod';
|
|
3
|
+
import { ValidationError } from './errorHandler.js';
|
|
4
|
+
|
|
5
|
+
export function validateBody<T>(schema: ZodSchema<T>) {
|
|
6
|
+
return (req: Request, _res: Response, next: NextFunction): void => {
|
|
7
|
+
try {
|
|
8
|
+
req.body = schema.parse(req.body);
|
|
9
|
+
next();
|
|
10
|
+
} catch (error) {
|
|
11
|
+
if (error instanceof ZodError) {
|
|
12
|
+
const messages = error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
|
|
13
|
+
next(new ValidationError(messages));
|
|
14
|
+
} else {
|
|
15
|
+
next(error);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function validateQuery<T>(schema: ZodSchema<T>) {
|
|
22
|
+
return (req: Request, _res: Response, next: NextFunction): void => {
|
|
23
|
+
try {
|
|
24
|
+
req.query = schema.parse(req.query) as any;
|
|
25
|
+
next();
|
|
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
|
+
} else {
|
|
31
|
+
next(error);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function validateParams<T>(schema: ZodSchema<T>) {
|
|
38
|
+
return (req: Request, _res: Response, next: NextFunction): void => {
|
|
39
|
+
try {
|
|
40
|
+
req.params = schema.parse(req.params) as any;
|
|
41
|
+
next();
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error instanceof ZodError) {
|
|
44
|
+
const messages = error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
|
|
45
|
+
next(new ValidationError(messages));
|
|
46
|
+
} else {
|
|
47
|
+
next(error);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Common validation schemas
|
|
54
|
+
export const schemas = {
|
|
55
|
+
uuid: z.string().uuid(),
|
|
56
|
+
email: z.string().email(),
|
|
57
|
+
|
|
58
|
+
// Auth schemas
|
|
59
|
+
gmailCallback: z.object({
|
|
60
|
+
code: z.string().min(1, 'Authorization code is required'),
|
|
61
|
+
}),
|
|
62
|
+
|
|
63
|
+
deviceFlow: z.object({
|
|
64
|
+
device_code: z.string().min(1, 'Device code is required'),
|
|
65
|
+
}),
|
|
66
|
+
|
|
67
|
+
// Sync schemas
|
|
68
|
+
syncRequest: z.object({
|
|
69
|
+
accountId: z.string().uuid('Invalid account ID'),
|
|
70
|
+
}),
|
|
71
|
+
|
|
72
|
+
// Action schemas
|
|
73
|
+
executeAction: z.object({
|
|
74
|
+
emailId: z.string().uuid('Invalid email ID'),
|
|
75
|
+
action: z.enum(['delete', 'archive', 'draft', 'flag', 'none']),
|
|
76
|
+
draftContent: z.string().optional(),
|
|
77
|
+
}),
|
|
78
|
+
|
|
79
|
+
// Migration schemas
|
|
80
|
+
migrate: z.object({
|
|
81
|
+
projectRef: z.string().min(1, 'Project reference is required'),
|
|
82
|
+
dbPassword: z.string().optional(),
|
|
83
|
+
accessToken: z.string().optional(),
|
|
84
|
+
}),
|
|
85
|
+
|
|
86
|
+
// Rule schemas
|
|
87
|
+
createRule: z.object({
|
|
88
|
+
name: z.string().min(1).max(100),
|
|
89
|
+
condition: z.record(z.unknown()),
|
|
90
|
+
action: z.enum(['delete', 'archive', 'draft', 'star', 'read']),
|
|
91
|
+
instructions: z.string().optional(),
|
|
92
|
+
is_enabled: z.boolean().default(true),
|
|
93
|
+
}),
|
|
94
|
+
|
|
95
|
+
updateRule: z.object({
|
|
96
|
+
name: z.string().min(1).max(100).optional(),
|
|
97
|
+
condition: z.record(z.unknown()).optional(),
|
|
98
|
+
action: z.enum(['delete', 'archive', 'draft', 'star', 'read']).optional(),
|
|
99
|
+
instructions: z.string().optional(),
|
|
100
|
+
is_enabled: z.boolean().optional(),
|
|
101
|
+
}),
|
|
102
|
+
|
|
103
|
+
// Settings schemas
|
|
104
|
+
updateSettings: z.object({
|
|
105
|
+
llm_model: z.string().optional(),
|
|
106
|
+
llm_base_url: z.string().url().optional().or(z.literal('')),
|
|
107
|
+
llm_api_key: z.string().optional(),
|
|
108
|
+
auto_trash_spam: z.boolean().optional(),
|
|
109
|
+
smart_drafts: z.boolean().optional(),
|
|
110
|
+
sync_interval_minutes: z.number().min(1).max(60).optional(),
|
|
111
|
+
// BYOK Credentials (transient, moved to integrations)
|
|
112
|
+
google_client_id: z.string().optional(),
|
|
113
|
+
google_client_secret: z.string().optional(),
|
|
114
|
+
microsoft_client_id: z.string().optional(),
|
|
115
|
+
microsoft_client_secret: z.string().optional(),
|
|
116
|
+
microsoft_tenant_id: z.string().optional(),
|
|
117
|
+
}),
|
|
118
|
+
};
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { asyncHandler, NotFoundError } from '../middleware/errorHandler.js';
|
|
3
|
+
import { authMiddleware } from '../middleware/auth.js';
|
|
4
|
+
import { apiRateLimit } from '../middleware/rateLimit.js';
|
|
5
|
+
import { validateBody, schemas } from '../middleware/validation.js';
|
|
6
|
+
import { getGmailService } from '../services/gmail.js';
|
|
7
|
+
import { getMicrosoftService } from '../services/microsoft.js';
|
|
8
|
+
import { getIntelligenceService } from '../services/intelligence.js';
|
|
9
|
+
import { createLogger } from '../utils/logger.js';
|
|
10
|
+
|
|
11
|
+
const router = Router();
|
|
12
|
+
const logger = createLogger('ActionRoutes');
|
|
13
|
+
|
|
14
|
+
// Execute action on email
|
|
15
|
+
router.post('/execute',
|
|
16
|
+
apiRateLimit,
|
|
17
|
+
authMiddleware,
|
|
18
|
+
validateBody(schemas.executeAction),
|
|
19
|
+
asyncHandler(async (req, res) => {
|
|
20
|
+
const { emailId, action, draftContent } = req.body;
|
|
21
|
+
const userId = req.user!.id;
|
|
22
|
+
|
|
23
|
+
// Fetch email with account info
|
|
24
|
+
const { data: email, error } = await req.supabase!
|
|
25
|
+
.from('emails')
|
|
26
|
+
.select('*, email_accounts(*)')
|
|
27
|
+
.eq('id', emailId)
|
|
28
|
+
.single();
|
|
29
|
+
|
|
30
|
+
if (error || !email) {
|
|
31
|
+
throw new NotFoundError('Email');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Verify ownership
|
|
35
|
+
if (email.email_accounts.user_id !== userId) {
|
|
36
|
+
throw new NotFoundError('Email');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const account = email.email_accounts;
|
|
40
|
+
let actionResult = { success: true, details: '' };
|
|
41
|
+
|
|
42
|
+
if (action === 'none') {
|
|
43
|
+
// Just mark as reviewed
|
|
44
|
+
await req.supabase!
|
|
45
|
+
.from('emails')
|
|
46
|
+
.update({ action_taken: 'none' })
|
|
47
|
+
.eq('id', emailId);
|
|
48
|
+
} else if (account.provider === 'gmail') {
|
|
49
|
+
const gmailService = getGmailService();
|
|
50
|
+
|
|
51
|
+
if (action === 'delete') {
|
|
52
|
+
await gmailService.trashMessage(account, email.external_id);
|
|
53
|
+
} else if (action === 'archive') {
|
|
54
|
+
await gmailService.archiveMessage(account, email.external_id);
|
|
55
|
+
} else if (action === 'draft') {
|
|
56
|
+
const content = draftContent || email.ai_analysis?.draft_response || '';
|
|
57
|
+
if (content) {
|
|
58
|
+
const draftId = await gmailService.createDraft(account, email.external_id, content);
|
|
59
|
+
actionResult.details = `Draft created: ${draftId}`;
|
|
60
|
+
}
|
|
61
|
+
} else if (action === 'flag') {
|
|
62
|
+
await gmailService.addLabel(account, email.external_id, ['STARRED']);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await req.supabase!
|
|
66
|
+
.from('emails')
|
|
67
|
+
.update({ action_taken: action })
|
|
68
|
+
.eq('id', emailId);
|
|
69
|
+
} else if (account.provider === 'outlook') {
|
|
70
|
+
const microsoftService = getMicrosoftService();
|
|
71
|
+
|
|
72
|
+
if (action === 'delete') {
|
|
73
|
+
await microsoftService.trashMessage(account, email.external_id);
|
|
74
|
+
} else if (action === 'archive') {
|
|
75
|
+
await microsoftService.archiveMessage(account, email.external_id);
|
|
76
|
+
} else if (action === 'draft') {
|
|
77
|
+
const content = draftContent || email.ai_analysis?.draft_response || '';
|
|
78
|
+
if (content) {
|
|
79
|
+
const draftId = await microsoftService.createDraft(account, email.external_id, content);
|
|
80
|
+
actionResult.details = `Draft created: ${draftId}`;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
await req.supabase!
|
|
85
|
+
.from('emails')
|
|
86
|
+
.update({ action_taken: action })
|
|
87
|
+
.eq('id', emailId);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
logger.info('Action executed', { emailId, action, userId });
|
|
91
|
+
|
|
92
|
+
res.json(actionResult);
|
|
93
|
+
})
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// Generate AI draft for an email
|
|
97
|
+
router.post('/draft/:emailId',
|
|
98
|
+
apiRateLimit,
|
|
99
|
+
authMiddleware,
|
|
100
|
+
asyncHandler(async (req, res) => {
|
|
101
|
+
const { emailId } = req.params;
|
|
102
|
+
const { instructions } = req.body;
|
|
103
|
+
const userId = req.user!.id;
|
|
104
|
+
|
|
105
|
+
// Fetch email
|
|
106
|
+
const { data: email, error } = await req.supabase!
|
|
107
|
+
.from('emails')
|
|
108
|
+
.select('*, email_accounts(user_id)')
|
|
109
|
+
.eq('id', emailId)
|
|
110
|
+
.single();
|
|
111
|
+
|
|
112
|
+
if (error || !email) {
|
|
113
|
+
throw new NotFoundError('Email');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (email.email_accounts.user_id !== userId) {
|
|
117
|
+
throw new NotFoundError('Email');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Get user settings for LLM config
|
|
121
|
+
const { data: settings } = await req.supabase!
|
|
122
|
+
.from('user_settings')
|
|
123
|
+
.select('llm_model, llm_base_url')
|
|
124
|
+
.eq('user_id', userId)
|
|
125
|
+
.single();
|
|
126
|
+
|
|
127
|
+
const intelligenceService = getIntelligenceService(
|
|
128
|
+
settings ? { model: settings.llm_model, baseUrl: settings.llm_base_url } : undefined
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const draft = await intelligenceService.generateDraftReply(
|
|
132
|
+
{
|
|
133
|
+
subject: email.subject,
|
|
134
|
+
sender: email.sender,
|
|
135
|
+
body: email.body_snippet,
|
|
136
|
+
},
|
|
137
|
+
instructions
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
if (!draft) {
|
|
141
|
+
return res.status(500).json({ error: 'Failed to generate draft' });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
res.json({ draft });
|
|
145
|
+
})
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// Bulk actions
|
|
149
|
+
router.post('/bulk',
|
|
150
|
+
apiRateLimit,
|
|
151
|
+
authMiddleware,
|
|
152
|
+
asyncHandler(async (req, res) => {
|
|
153
|
+
const { emailIds, action } = req.body;
|
|
154
|
+
const userId = req.user!.id;
|
|
155
|
+
|
|
156
|
+
if (!Array.isArray(emailIds) || emailIds.length === 0) {
|
|
157
|
+
return res.status(400).json({ error: 'emailIds must be a non-empty array' });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!['delete', 'archive', 'none'].includes(action)) {
|
|
161
|
+
return res.status(400).json({ error: 'Invalid action for bulk operation' });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const results = { success: 0, failed: 0 };
|
|
165
|
+
|
|
166
|
+
for (const emailId of emailIds) {
|
|
167
|
+
try {
|
|
168
|
+
// Fetch email
|
|
169
|
+
const { data: email } = await req.supabase!
|
|
170
|
+
.from('emails')
|
|
171
|
+
.select('*, email_accounts(*)')
|
|
172
|
+
.eq('id', emailId)
|
|
173
|
+
.single();
|
|
174
|
+
|
|
175
|
+
if (!email || email.email_accounts.user_id !== userId) {
|
|
176
|
+
results.failed++;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const account = email.email_accounts;
|
|
181
|
+
|
|
182
|
+
if (account.provider === 'gmail') {
|
|
183
|
+
const gmailService = getGmailService();
|
|
184
|
+
if (action === 'delete') {
|
|
185
|
+
await gmailService.trashMessage(account, email.external_id);
|
|
186
|
+
} else if (action === 'archive') {
|
|
187
|
+
await gmailService.archiveMessage(account, email.external_id);
|
|
188
|
+
}
|
|
189
|
+
} else if (account.provider === 'outlook') {
|
|
190
|
+
const microsoftService = getMicrosoftService();
|
|
191
|
+
if (action === 'delete') {
|
|
192
|
+
await microsoftService.trashMessage(account, email.external_id);
|
|
193
|
+
} else if (action === 'archive') {
|
|
194
|
+
await microsoftService.archiveMessage(account, email.external_id);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
await req.supabase!
|
|
199
|
+
.from('emails')
|
|
200
|
+
.update({ action_taken: action })
|
|
201
|
+
.eq('id', emailId);
|
|
202
|
+
|
|
203
|
+
results.success++;
|
|
204
|
+
} catch (err) {
|
|
205
|
+
logger.error('Bulk action failed for email', err, { emailId });
|
|
206
|
+
results.failed++;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
res.json(results);
|
|
211
|
+
})
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
export default router;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { asyncHandler, ValidationError } from '../middleware/errorHandler.js';
|
|
3
|
+
import { authMiddleware } from '../middleware/auth.js';
|
|
4
|
+
import { authRateLimit } from '../middleware/rateLimit.js';
|
|
5
|
+
import { validateBody, schemas } from '../middleware/validation.js';
|
|
6
|
+
import { getGmailService } from '../services/gmail.js';
|
|
7
|
+
import { getMicrosoftService } from '../services/microsoft.js';
|
|
8
|
+
import { createLogger } from '../utils/logger.js';
|
|
9
|
+
|
|
10
|
+
const router = Router();
|
|
11
|
+
const logger = createLogger('AuthRoutes');
|
|
12
|
+
|
|
13
|
+
// Gmail OAuth
|
|
14
|
+
router.get('/gmail/url', authRateLimit, asyncHandler(async (req, res) => {
|
|
15
|
+
const gmailService = getGmailService();
|
|
16
|
+
const url = gmailService.getAuthUrl();
|
|
17
|
+
logger.info('Gmail auth URL generated');
|
|
18
|
+
res.json({ url });
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
router.post('/gmail/callback',
|
|
22
|
+
authRateLimit,
|
|
23
|
+
authMiddleware,
|
|
24
|
+
validateBody(schemas.gmailCallback),
|
|
25
|
+
asyncHandler(async (req, res) => {
|
|
26
|
+
const { code } = req.body;
|
|
27
|
+
const gmailService = getGmailService();
|
|
28
|
+
|
|
29
|
+
// Exchange code for tokens
|
|
30
|
+
const tokens = await gmailService.exchangeCode(code);
|
|
31
|
+
|
|
32
|
+
if (!tokens.access_token) {
|
|
33
|
+
throw new ValidationError('Failed to obtain access token');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Get email address from Gmail profile
|
|
37
|
+
const tempAccount = {
|
|
38
|
+
id: '',
|
|
39
|
+
user_id: req.user!.id,
|
|
40
|
+
provider: 'gmail' as const,
|
|
41
|
+
email_address: '',
|
|
42
|
+
access_token: tokens.access_token,
|
|
43
|
+
refresh_token: tokens.refresh_token || null,
|
|
44
|
+
token_expires_at: tokens.expiry_date ? new Date(tokens.expiry_date).toISOString() : null,
|
|
45
|
+
scopes: tokens.scope?.split(' ') || [],
|
|
46
|
+
is_active: true,
|
|
47
|
+
created_at: '',
|
|
48
|
+
updated_at: '',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const profile = await gmailService.getProfile(tempAccount);
|
|
52
|
+
|
|
53
|
+
// Save account
|
|
54
|
+
const account = await gmailService.saveAccount(
|
|
55
|
+
req.supabase!,
|
|
56
|
+
req.user!.id,
|
|
57
|
+
profile.emailAddress,
|
|
58
|
+
tokens
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
logger.info('Gmail account connected', {
|
|
62
|
+
userId: req.user!.id,
|
|
63
|
+
email: profile.emailAddress
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
res.json({
|
|
67
|
+
success: true,
|
|
68
|
+
account: {
|
|
69
|
+
id: account.id,
|
|
70
|
+
email_address: account.email_address,
|
|
71
|
+
provider: account.provider,
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
})
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Microsoft OAuth (Device Code Flow)
|
|
78
|
+
router.post('/microsoft/device-flow',
|
|
79
|
+
authRateLimit,
|
|
80
|
+
authMiddleware,
|
|
81
|
+
asyncHandler(async (req, res) => {
|
|
82
|
+
const microsoftService = getMicrosoftService();
|
|
83
|
+
|
|
84
|
+
// Start device code flow
|
|
85
|
+
const result = await microsoftService.acquireTokenByDeviceCode((response) => {
|
|
86
|
+
// This callback is called when the device code is ready
|
|
87
|
+
res.json({
|
|
88
|
+
userCode: response.userCode,
|
|
89
|
+
verificationUri: response.verificationUri,
|
|
90
|
+
message: response.message,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// If we get here, the user completed the flow
|
|
95
|
+
if (result) {
|
|
96
|
+
const account = await microsoftService.saveAccount(
|
|
97
|
+
req.supabase!,
|
|
98
|
+
req.user!.id,
|
|
99
|
+
result.account?.username || '',
|
|
100
|
+
result
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
logger.info('Microsoft account connected', {
|
|
104
|
+
userId: req.user!.id,
|
|
105
|
+
email: account.email_address,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
router.post('/microsoft/complete',
|
|
112
|
+
authRateLimit,
|
|
113
|
+
authMiddleware,
|
|
114
|
+
validateBody(schemas.deviceFlow),
|
|
115
|
+
asyncHandler(async (_req, res) => {
|
|
116
|
+
// Device flow completion is handled in the device-flow endpoint
|
|
117
|
+
// This is kept for backwards compatibility
|
|
118
|
+
res.json({ success: true });
|
|
119
|
+
})
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// Disconnect account
|
|
123
|
+
router.delete('/accounts/:accountId',
|
|
124
|
+
authMiddleware,
|
|
125
|
+
asyncHandler(async (req, res) => {
|
|
126
|
+
const { accountId } = req.params;
|
|
127
|
+
|
|
128
|
+
const { error } = await req.supabase!
|
|
129
|
+
.from('email_accounts')
|
|
130
|
+
.delete()
|
|
131
|
+
.eq('id', accountId)
|
|
132
|
+
.eq('user_id', req.user!.id);
|
|
133
|
+
|
|
134
|
+
if (error) throw error;
|
|
135
|
+
|
|
136
|
+
logger.info('Account disconnected', { accountId, userId: req.user!.id });
|
|
137
|
+
res.json({ success: true });
|
|
138
|
+
})
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// List connected accounts
|
|
142
|
+
router.get('/accounts',
|
|
143
|
+
authMiddleware,
|
|
144
|
+
asyncHandler(async (req, res) => {
|
|
145
|
+
const { data, error } = await req.supabase!
|
|
146
|
+
.from('email_accounts')
|
|
147
|
+
.select('id, provider, email_address, is_active, created_at, updated_at')
|
|
148
|
+
.eq('user_id', req.user!.id)
|
|
149
|
+
.order('created_at', { ascending: false });
|
|
150
|
+
|
|
151
|
+
if (error) throw error;
|
|
152
|
+
|
|
153
|
+
res.json({ accounts: data || [] });
|
|
154
|
+
})
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
export default router;
|