@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,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;