@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,152 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { asyncHandler } from '../middleware/errorHandler.js';
|
|
3
|
+
import { authMiddleware } from '../middleware/auth.js';
|
|
4
|
+
import { syncRateLimit } from '../middleware/rateLimit.js';
|
|
5
|
+
import { validateBody, schemas } from '../middleware/validation.js';
|
|
6
|
+
import { EmailProcessorService } from '../services/processor.js';
|
|
7
|
+
import { createLogger } from '../utils/logger.js';
|
|
8
|
+
|
|
9
|
+
const router = Router();
|
|
10
|
+
const logger = createLogger('SyncRoutes');
|
|
11
|
+
|
|
12
|
+
// Trigger sync for an account
|
|
13
|
+
router.post('/',
|
|
14
|
+
syncRateLimit,
|
|
15
|
+
authMiddleware,
|
|
16
|
+
validateBody(schemas.syncRequest),
|
|
17
|
+
asyncHandler(async (req, res) => {
|
|
18
|
+
const { accountId } = req.body;
|
|
19
|
+
const userId = req.user!.id;
|
|
20
|
+
|
|
21
|
+
if (!req.supabase) {
|
|
22
|
+
return res.status(503).json({
|
|
23
|
+
error: 'Supabase service is not configured. Please set your SUPABASE_URL and SUPABASE_ANON_KEY in the .env file and restart the server.'
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Verify account ownership
|
|
28
|
+
const { data: account, error } = await req.supabase!
|
|
29
|
+
.from('email_accounts')
|
|
30
|
+
.select('id')
|
|
31
|
+
.eq('id', accountId)
|
|
32
|
+
.eq('user_id', userId)
|
|
33
|
+
.single();
|
|
34
|
+
|
|
35
|
+
if (error || !account) {
|
|
36
|
+
return res.status(404).json({ error: 'Account not found' });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Run sync and wait for result
|
|
40
|
+
const processor = new EmailProcessorService(req.supabase!);
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const result = await processor.syncAccount(accountId, userId);
|
|
44
|
+
logger.info('Sync completed', { accountId, ...result });
|
|
45
|
+
res.json({
|
|
46
|
+
message: 'Sync completed',
|
|
47
|
+
accountId,
|
|
48
|
+
...result,
|
|
49
|
+
});
|
|
50
|
+
} catch (err) {
|
|
51
|
+
logger.error('Sync failed', err, { accountId });
|
|
52
|
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
53
|
+
res.status(500).json({
|
|
54
|
+
error: errorMessage,
|
|
55
|
+
accountId,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Sync all accounts for user
|
|
62
|
+
router.post('/all',
|
|
63
|
+
syncRateLimit,
|
|
64
|
+
authMiddleware,
|
|
65
|
+
asyncHandler(async (req, res) => {
|
|
66
|
+
const userId = req.user!.id;
|
|
67
|
+
|
|
68
|
+
if (!req.supabase) {
|
|
69
|
+
return res.status(503).json({
|
|
70
|
+
error: 'Supabase service is not configured. Please set your SUPABASE_URL and SUPABASE_ANON_KEY in the .env file and restart the server.'
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const { data: accounts, error } = await req.supabase!
|
|
75
|
+
.from('email_accounts')
|
|
76
|
+
.select('id')
|
|
77
|
+
.eq('user_id', userId)
|
|
78
|
+
.eq('is_active', true);
|
|
79
|
+
|
|
80
|
+
if (error) throw error;
|
|
81
|
+
|
|
82
|
+
if (!accounts || accounts.length === 0) {
|
|
83
|
+
return res.status(400).json({ error: 'No connected accounts' });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const processor = new EmailProcessorService(req.supabase!);
|
|
87
|
+
|
|
88
|
+
// Sync all accounts and collect results
|
|
89
|
+
const results = await Promise.allSettled(
|
|
90
|
+
accounts.map(account => processor.syncAccount(account.id, userId))
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const summary = {
|
|
94
|
+
total: accounts.length,
|
|
95
|
+
success: 0,
|
|
96
|
+
failed: 0,
|
|
97
|
+
errors: [] as { accountId: string; error: string }[],
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
results.forEach((result, index) => {
|
|
101
|
+
if (result.status === 'fulfilled') {
|
|
102
|
+
summary.success++;
|
|
103
|
+
logger.info('Sync completed', { accountId: accounts[index].id, ...result.value });
|
|
104
|
+
} else {
|
|
105
|
+
summary.failed++;
|
|
106
|
+
const errorMessage = result.reason instanceof Error ? result.reason.message : 'Unknown error';
|
|
107
|
+
summary.errors.push({ accountId: accounts[index].id, error: errorMessage });
|
|
108
|
+
logger.error('Sync failed', result.reason, { accountId: accounts[index].id });
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Return error status if all syncs failed
|
|
113
|
+
if (summary.failed === summary.total) {
|
|
114
|
+
return res.status(500).json({
|
|
115
|
+
message: 'All syncs failed',
|
|
116
|
+
...summary,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
res.json({
|
|
121
|
+
message: summary.failed > 0 ? 'Sync completed with errors' : 'Sync completed',
|
|
122
|
+
...summary,
|
|
123
|
+
});
|
|
124
|
+
})
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Get sync history/logs
|
|
128
|
+
router.get('/logs',
|
|
129
|
+
authMiddleware,
|
|
130
|
+
asyncHandler(async (req, res) => {
|
|
131
|
+
const { limit = '10' } = req.query;
|
|
132
|
+
|
|
133
|
+
if (!req.supabase) {
|
|
134
|
+
return res.status(503).json({
|
|
135
|
+
error: 'Supabase service is not configured. Please set your SUPABASE_URL and SUPABASE_ANON_KEY in the .env file and restart the server.'
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const { data, error } = await req.supabase!
|
|
140
|
+
.from('processing_logs')
|
|
141
|
+
.select('*')
|
|
142
|
+
.eq('user_id', req.user!.id)
|
|
143
|
+
.order('started_at', { ascending: false })
|
|
144
|
+
.limit(parseInt(limit as string, 10));
|
|
145
|
+
|
|
146
|
+
if (error) throw error;
|
|
147
|
+
|
|
148
|
+
res.json({ logs: data || [] });
|
|
149
|
+
})
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
export default router;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { SupabaseClient } from '@supabase/supabase-js';
|
|
2
|
+
import { createLogger } from '../utils/logger.js';
|
|
3
|
+
|
|
4
|
+
const logger = createLogger('EventLogger');
|
|
5
|
+
|
|
6
|
+
export class EventLogger {
|
|
7
|
+
constructor(
|
|
8
|
+
private supabase: SupabaseClient,
|
|
9
|
+
private runId: string
|
|
10
|
+
) {}
|
|
11
|
+
|
|
12
|
+
async log(
|
|
13
|
+
eventType: 'info' | 'analysis' | 'action' | 'error',
|
|
14
|
+
agentState: string,
|
|
15
|
+
details?: any,
|
|
16
|
+
emailId?: string
|
|
17
|
+
) {
|
|
18
|
+
try {
|
|
19
|
+
const { error } = await this.supabase.from('processing_events').insert({
|
|
20
|
+
run_id: this.runId,
|
|
21
|
+
email_id: emailId || null,
|
|
22
|
+
event_type: eventType,
|
|
23
|
+
agent_state: agentState,
|
|
24
|
+
details: details || {},
|
|
25
|
+
created_at: new Date().toISOString()
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
if (error) {
|
|
29
|
+
console.error('[EventLogger] Supabase Insert Error:', error);
|
|
30
|
+
}
|
|
31
|
+
} catch (error) {
|
|
32
|
+
// Non-blocking error logging - don't fail the job because logging failed
|
|
33
|
+
logger.error('Failed to write processing event', error);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async info(state: string, message: string, details?: any, emailId?: string) {
|
|
38
|
+
await this.log('info', state, { message, ...details }, emailId);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async analysis(state: string, emailId: string, analysis: any) {
|
|
42
|
+
await this.log('analysis', state, analysis, emailId);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async action(state: string, emailId: string, action: string, reason?: string) {
|
|
46
|
+
await this.log('action', state, { action, reason }, emailId);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async error(state: string, error: any, emailId?: string) {
|
|
50
|
+
await this.log('error', state, { error: error.message || error }, emailId);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { google, gmail_v1, Auth } from 'googleapis';
|
|
2
|
+
import { SupabaseClient } from '@supabase/supabase-js';
|
|
3
|
+
import { config } from '../config/index.js';
|
|
4
|
+
import { createLogger } from '../utils/logger.js';
|
|
5
|
+
// Tokens are stored without encryption, protected by Supabase RLS
|
|
6
|
+
import { EmailAccount } from './supabase.js';
|
|
7
|
+
|
|
8
|
+
const logger = createLogger('GmailService');
|
|
9
|
+
|
|
10
|
+
export interface RuleAttachment {
|
|
11
|
+
name: string;
|
|
12
|
+
path: string;
|
|
13
|
+
type: string;
|
|
14
|
+
size: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface GmailMessage {
|
|
18
|
+
id: string;
|
|
19
|
+
threadId: string;
|
|
20
|
+
subject: string;
|
|
21
|
+
sender: string;
|
|
22
|
+
recipient: string;
|
|
23
|
+
date: string;
|
|
24
|
+
body: string;
|
|
25
|
+
snippet: string;
|
|
26
|
+
headers: {
|
|
27
|
+
importance?: string;
|
|
28
|
+
listUnsubscribe?: string;
|
|
29
|
+
autoSubmitted?: string;
|
|
30
|
+
mailer?: string;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface OAuthCredentials {
|
|
35
|
+
clientId: string;
|
|
36
|
+
clientSecret: string;
|
|
37
|
+
redirectUri?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class GmailService {
|
|
41
|
+
private createOAuth2Client(credentials?: OAuthCredentials): Auth.OAuth2Client {
|
|
42
|
+
return new google.auth.OAuth2(
|
|
43
|
+
credentials?.clientId || config.gmail.clientId,
|
|
44
|
+
credentials?.clientSecret || config.gmail.clientSecret,
|
|
45
|
+
credentials?.redirectUri || config.gmail.redirectUri
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async getProviderCredentials(supabase: SupabaseClient, userId: string): Promise<OAuthCredentials> {
|
|
50
|
+
const { data: integration } = await supabase
|
|
51
|
+
.from('integrations')
|
|
52
|
+
.select('credentials')
|
|
53
|
+
.eq('user_id', userId)
|
|
54
|
+
.eq('provider', 'google')
|
|
55
|
+
.single();
|
|
56
|
+
|
|
57
|
+
const creds = integration?.credentials as any;
|
|
58
|
+
|
|
59
|
+
if (creds?.client_id && creds?.client_secret) {
|
|
60
|
+
return {
|
|
61
|
+
clientId: creds.client_id,
|
|
62
|
+
clientSecret: creds.client_secret,
|
|
63
|
+
redirectUri: config.gmail.redirectUri
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (config.gmail.clientId && config.gmail.clientSecret) {
|
|
68
|
+
return {
|
|
69
|
+
clientId: config.gmail.clientId,
|
|
70
|
+
clientSecret: config.gmail.clientSecret,
|
|
71
|
+
redirectUri: config.gmail.redirectUri
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
throw new Error('Gmail OAuth credentials not configured (Database or Env)');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
getAuthUrl(scopes: string[] = ['https://www.googleapis.com/auth/gmail.modify']): string {
|
|
79
|
+
const client = this.createOAuth2Client();
|
|
80
|
+
return client.generateAuthUrl({
|
|
81
|
+
access_type: 'offline',
|
|
82
|
+
scope: scopes,
|
|
83
|
+
prompt: 'consent',
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async exchangeCode(code: string): Promise<{
|
|
88
|
+
access_token: string;
|
|
89
|
+
refresh_token?: string;
|
|
90
|
+
expiry_date?: number;
|
|
91
|
+
scope?: string;
|
|
92
|
+
}> {
|
|
93
|
+
const client = this.createOAuth2Client();
|
|
94
|
+
const { tokens } = await client.getToken(code);
|
|
95
|
+
return {
|
|
96
|
+
access_token: tokens.access_token!,
|
|
97
|
+
refresh_token: tokens.refresh_token ?? undefined,
|
|
98
|
+
expiry_date: tokens.expiry_date ?? undefined,
|
|
99
|
+
scope: tokens.scope ?? undefined,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async saveAccount(
|
|
104
|
+
supabase: SupabaseClient,
|
|
105
|
+
userId: string,
|
|
106
|
+
emailAddress: string,
|
|
107
|
+
tokens: { access_token: string; refresh_token?: string; expiry_date?: number; scope?: string }
|
|
108
|
+
): Promise<EmailAccount> {
|
|
109
|
+
const { data, error } = await supabase
|
|
110
|
+
.from('email_accounts')
|
|
111
|
+
.upsert({
|
|
112
|
+
user_id: userId,
|
|
113
|
+
email_address: emailAddress,
|
|
114
|
+
provider: 'gmail',
|
|
115
|
+
access_token: tokens.access_token,
|
|
116
|
+
refresh_token: tokens.refresh_token || null,
|
|
117
|
+
token_expires_at: tokens.expiry_date ? new Date(tokens.expiry_date).toISOString() : null,
|
|
118
|
+
scopes: tokens.scope?.split(' ') || [],
|
|
119
|
+
is_active: true,
|
|
120
|
+
updated_at: new Date().toISOString(),
|
|
121
|
+
}, { onConflict: 'user_id, email_address' })
|
|
122
|
+
.select()
|
|
123
|
+
.single();
|
|
124
|
+
|
|
125
|
+
if (error) throw error;
|
|
126
|
+
return data;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private async getAuthenticatedClient(account: EmailAccount): Promise<gmail_v1.Gmail> {
|
|
130
|
+
const accessToken = account.access_token || '';
|
|
131
|
+
const refreshToken = account.refresh_token || '';
|
|
132
|
+
const client = this.createOAuth2Client();
|
|
133
|
+
|
|
134
|
+
client.setCredentials({
|
|
135
|
+
access_token: accessToken,
|
|
136
|
+
refresh_token: refreshToken,
|
|
137
|
+
expiry_date: account.token_expires_at ? new Date(account.token_expires_at).getTime() : undefined,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return google.gmail({ version: 'v1', auth: client });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async refreshTokenIfNeeded(
|
|
144
|
+
supabase: SupabaseClient,
|
|
145
|
+
account: EmailAccount
|
|
146
|
+
): Promise<EmailAccount> {
|
|
147
|
+
if (!account.token_expires_at) return account;
|
|
148
|
+
|
|
149
|
+
const expiresAt = new Date(account.token_expires_at).getTime();
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
const bufferMs = 5 * 60 * 1000; // 5 minutes buffer
|
|
152
|
+
|
|
153
|
+
if (expiresAt > now + bufferMs) {
|
|
154
|
+
return account; // Token still valid
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
logger.info('Refreshing Gmail token', { accountId: account.id });
|
|
158
|
+
|
|
159
|
+
const refreshToken = account.refresh_token;
|
|
160
|
+
if (!refreshToken) {
|
|
161
|
+
throw new Error('No refresh token available');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const credentials = await this.getProviderCredentials(supabase, account.user_id);
|
|
165
|
+
const client = this.createOAuth2Client(credentials);
|
|
166
|
+
client.setCredentials({ refresh_token: refreshToken });
|
|
167
|
+
const { credentials: newTokens } = await client.refreshAccessToken();
|
|
168
|
+
|
|
169
|
+
const { data, error } = await supabase
|
|
170
|
+
.from('email_accounts')
|
|
171
|
+
.update({
|
|
172
|
+
access_token: newTokens.access_token!,
|
|
173
|
+
token_expires_at: newTokens.expiry_date
|
|
174
|
+
? new Date(newTokens.expiry_date).toISOString()
|
|
175
|
+
: null,
|
|
176
|
+
updated_at: new Date().toISOString(),
|
|
177
|
+
})
|
|
178
|
+
.eq('id', account.id)
|
|
179
|
+
.select()
|
|
180
|
+
.single();
|
|
181
|
+
|
|
182
|
+
if (error) throw error;
|
|
183
|
+
return data;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async fetchMessages(
|
|
187
|
+
account: EmailAccount,
|
|
188
|
+
options: { maxResults?: number; query?: string; pageToken?: string } = {}
|
|
189
|
+
): Promise<{ messages: GmailMessage[]; nextPageToken?: string }> {
|
|
190
|
+
const gmail = await this.getAuthenticatedClient(account);
|
|
191
|
+
const { maxResults = config.processing.batchSize, query, pageToken } = options;
|
|
192
|
+
|
|
193
|
+
const response = await gmail.users.messages.list({
|
|
194
|
+
userId: 'me',
|
|
195
|
+
maxResults,
|
|
196
|
+
q: query,
|
|
197
|
+
pageToken,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const messages: GmailMessage[] = [];
|
|
201
|
+
|
|
202
|
+
for (const msg of response.data.messages || []) {
|
|
203
|
+
if (!msg.id) continue;
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const detail = await gmail.users.messages.get({
|
|
207
|
+
userId: 'me',
|
|
208
|
+
id: msg.id,
|
|
209
|
+
format: 'full',
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const parsed = this.parseMessage(detail.data);
|
|
213
|
+
if (parsed) {
|
|
214
|
+
messages.push(parsed);
|
|
215
|
+
}
|
|
216
|
+
} catch (error) {
|
|
217
|
+
logger.warn('Failed to fetch message details', { messageId: msg.id, error });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
messages,
|
|
223
|
+
nextPageToken: response.data.nextPageToken ?? undefined,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private parseMessage(message: gmail_v1.Schema$Message): GmailMessage | null {
|
|
228
|
+
if (!message.id || !message.threadId) return null;
|
|
229
|
+
|
|
230
|
+
const headers = message.payload?.headers || [];
|
|
231
|
+
const getHeader = (name: string) => headers.find(h => h.name?.toLowerCase() === name.toLowerCase())?.value || '';
|
|
232
|
+
|
|
233
|
+
let body = '';
|
|
234
|
+
const payload = message.payload;
|
|
235
|
+
|
|
236
|
+
if (payload?.parts) {
|
|
237
|
+
// Multipart message
|
|
238
|
+
const textPart = payload.parts.find(p => p.mimeType === 'text/plain');
|
|
239
|
+
const htmlPart = payload.parts.find(p => p.mimeType === 'text/html');
|
|
240
|
+
const part = textPart || htmlPart || payload.parts[0];
|
|
241
|
+
body = this.decodeBody(part?.body?.data);
|
|
242
|
+
} else if (payload?.body?.data) {
|
|
243
|
+
body = this.decodeBody(payload.body.data);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
id: message.id,
|
|
248
|
+
threadId: message.threadId,
|
|
249
|
+
subject: getHeader('Subject') || 'No Subject',
|
|
250
|
+
sender: getHeader('From'),
|
|
251
|
+
recipient: getHeader('To'),
|
|
252
|
+
date: getHeader('Date'),
|
|
253
|
+
body,
|
|
254
|
+
snippet: message.snippet || '',
|
|
255
|
+
headers: {
|
|
256
|
+
importance: getHeader('Importance') || getHeader('X-Priority'),
|
|
257
|
+
listUnsubscribe: getHeader('List-Unsubscribe'),
|
|
258
|
+
autoSubmitted: getHeader('Auto-Submitted'),
|
|
259
|
+
mailer: getHeader('X-Mailer'),
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private decodeBody(data?: string | null): string {
|
|
265
|
+
if (!data) return '';
|
|
266
|
+
try {
|
|
267
|
+
return Buffer.from(data, 'base64').toString('utf-8');
|
|
268
|
+
} catch {
|
|
269
|
+
return '';
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async trashMessage(account: EmailAccount, messageId: string): Promise<void> {
|
|
274
|
+
const gmail = await this.getAuthenticatedClient(account);
|
|
275
|
+
await gmail.users.messages.trash({ userId: 'me', id: messageId });
|
|
276
|
+
logger.debug('Message trashed', { messageId });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async archiveMessage(account: EmailAccount, messageId: string): Promise<void> {
|
|
280
|
+
const gmail = await this.getAuthenticatedClient(account);
|
|
281
|
+
await gmail.users.messages.modify({
|
|
282
|
+
userId: 'me',
|
|
283
|
+
id: messageId,
|
|
284
|
+
requestBody: { removeLabelIds: ['INBOX'] },
|
|
285
|
+
});
|
|
286
|
+
logger.debug('Message archived', { messageId });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async createDraft(
|
|
290
|
+
account: EmailAccount,
|
|
291
|
+
originalMessageId: string,
|
|
292
|
+
replyContent: string,
|
|
293
|
+
supabase?: SupabaseClient,
|
|
294
|
+
attachments?: RuleAttachment[]
|
|
295
|
+
): Promise<string> {
|
|
296
|
+
const gmail = await this.getAuthenticatedClient(account);
|
|
297
|
+
|
|
298
|
+
// Fetch original message to get threadId and Message-ID for threading
|
|
299
|
+
const original = await gmail.users.messages.get({ userId: 'me', id: originalMessageId });
|
|
300
|
+
const headers = original.data.payload?.headers || [];
|
|
301
|
+
const getHeader = (name: string) => headers.find(h => h.name?.toLowerCase() === name.toLowerCase())?.value || '';
|
|
302
|
+
|
|
303
|
+
const toAddress = getHeader('From');
|
|
304
|
+
const originalSubject = getHeader('Subject');
|
|
305
|
+
const originalMsgId = getHeader('Message-ID');
|
|
306
|
+
const threadId = original.data.threadId;
|
|
307
|
+
|
|
308
|
+
// Ensure subject has Re: prefix
|
|
309
|
+
const subject = originalSubject.toLowerCase().startsWith('re:')
|
|
310
|
+
? originalSubject
|
|
311
|
+
: `Re: ${originalSubject}`;
|
|
312
|
+
|
|
313
|
+
logger.info('Creating draft', { threadId, toAddress, subject });
|
|
314
|
+
|
|
315
|
+
// Threading headers: In-Reply-To should be the Message-ID of the mail we reply to
|
|
316
|
+
const replyHeaders = [];
|
|
317
|
+
if (originalMsgId) {
|
|
318
|
+
replyHeaders.push(`In-Reply-To: ${originalMsgId}`);
|
|
319
|
+
replyHeaders.push(`References: ${originalMsgId}`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
let rawMessage = '';
|
|
323
|
+
const boundary = `----=_Part_${Math.random().toString(36).substring(2)}`;
|
|
324
|
+
|
|
325
|
+
if (attachments && attachments.length > 0 && supabase) {
|
|
326
|
+
// Multipart message
|
|
327
|
+
rawMessage = [
|
|
328
|
+
`To: ${toAddress}`,
|
|
329
|
+
`Subject: ${subject}`,
|
|
330
|
+
...replyHeaders,
|
|
331
|
+
'MIME-Version: 1.0',
|
|
332
|
+
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
|
333
|
+
'',
|
|
334
|
+
`--${boundary}`,
|
|
335
|
+
'Content-Type: text/plain; charset="UTF-8"',
|
|
336
|
+
'Content-Transfer-Encoding: 7bit',
|
|
337
|
+
'',
|
|
338
|
+
replyContent,
|
|
339
|
+
'',
|
|
340
|
+
].join('\r\n');
|
|
341
|
+
|
|
342
|
+
for (const attachment of attachments) {
|
|
343
|
+
try {
|
|
344
|
+
const content = await this.fetchAttachment(supabase, attachment.path);
|
|
345
|
+
const base64Content = Buffer.from(content).toString('base64');
|
|
346
|
+
|
|
347
|
+
rawMessage += [
|
|
348
|
+
`--${boundary}`,
|
|
349
|
+
`Content-Type: ${attachment.type}; name="${attachment.name}"`,
|
|
350
|
+
`Content-Disposition: attachment; filename="${attachment.name}"`,
|
|
351
|
+
'Content-Transfer-Encoding: base64',
|
|
352
|
+
'',
|
|
353
|
+
base64Content,
|
|
354
|
+
'',
|
|
355
|
+
].join('\r\n');
|
|
356
|
+
} catch (err) {
|
|
357
|
+
logger.error('Failed to attach file', err, { path: attachment.path });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
rawMessage += `--${boundary}--`;
|
|
362
|
+
} else {
|
|
363
|
+
// Simple plain text message
|
|
364
|
+
rawMessage = [
|
|
365
|
+
`To: ${toAddress}`,
|
|
366
|
+
`Subject: ${subject}`,
|
|
367
|
+
...replyHeaders,
|
|
368
|
+
'MIME-Version: 1.0',
|
|
369
|
+
'Content-Type: text/plain; charset="UTF-8"',
|
|
370
|
+
'',
|
|
371
|
+
replyContent,
|
|
372
|
+
].join('\r\n');
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const encodedMessage = Buffer.from(rawMessage)
|
|
376
|
+
.toString('base64')
|
|
377
|
+
.replace(/\+/g, '-')
|
|
378
|
+
.replace(/\//g, '_')
|
|
379
|
+
.replace(/=+$/, '');
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
const draft = await gmail.users.drafts.create({
|
|
383
|
+
userId: 'me',
|
|
384
|
+
requestBody: {
|
|
385
|
+
message: {
|
|
386
|
+
threadId,
|
|
387
|
+
raw: encodedMessage,
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
const draftId = draft.data.id || 'unknown';
|
|
393
|
+
logger.info('Draft created successfully', { draftId, threadId });
|
|
394
|
+
return draftId;
|
|
395
|
+
} catch (error) {
|
|
396
|
+
logger.error('Gmail API Error creating draft', error);
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async addLabel(account: EmailAccount, messageId: string, labelIds: string[]): Promise<void> {
|
|
402
|
+
const gmail = await this.getAuthenticatedClient(account);
|
|
403
|
+
await gmail.users.messages.modify({
|
|
404
|
+
userId: 'me',
|
|
405
|
+
id: messageId,
|
|
406
|
+
requestBody: { addLabelIds: labelIds },
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async removeLabel(account: EmailAccount, messageId: string, labelIds: string[]): Promise<void> {
|
|
411
|
+
const gmail = await this.getAuthenticatedClient(account);
|
|
412
|
+
await gmail.users.messages.modify({
|
|
413
|
+
userId: 'me',
|
|
414
|
+
id: messageId,
|
|
415
|
+
requestBody: { removeLabelIds: labelIds },
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async markAsRead(account: EmailAccount, messageId: string): Promise<void> {
|
|
420
|
+
await this.removeLabel(account, messageId, ['UNREAD']);
|
|
421
|
+
logger.debug('Message marked as read', { messageId });
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async starMessage(account: EmailAccount, messageId: string): Promise<void> {
|
|
425
|
+
await this.addLabel(account, messageId, ['STARRED']);
|
|
426
|
+
logger.debug('Message starred', { messageId });
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private async fetchAttachment(supabase: SupabaseClient, path: string): Promise<Uint8Array> {
|
|
430
|
+
const { data, error } = await supabase.storage
|
|
431
|
+
.from('rule-attachments')
|
|
432
|
+
.download(path);
|
|
433
|
+
|
|
434
|
+
if (error) throw error;
|
|
435
|
+
return new Uint8Array(await data.arrayBuffer());
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async getProfile(account: EmailAccount): Promise<{ emailAddress: string; messagesTotal: number }> {
|
|
439
|
+
const gmail = await this.getAuthenticatedClient(account);
|
|
440
|
+
const profile = await gmail.users.getProfile({ userId: 'me' });
|
|
441
|
+
return {
|
|
442
|
+
emailAddress: profile.data.emailAddress || '',
|
|
443
|
+
messagesTotal: profile.data.messagesTotal || 0,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Singleton
|
|
449
|
+
let instance: GmailService | null = null;
|
|
450
|
+
|
|
451
|
+
export function getGmailService(): GmailService {
|
|
452
|
+
if (!instance) {
|
|
453
|
+
instance = new GmailService();
|
|
454
|
+
}
|
|
455
|
+
return instance;
|
|
456
|
+
}
|