@vantageos/vantage-crm-mcp 0.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/README.md +260 -0
- package/dist/convex/crm/_helpers.js +24 -0
- package/dist/convex/crm/activities.js +220 -0
- package/dist/convex/crm/briefing.js +198 -0
- package/dist/convex/crm/calendarCron.js +92 -0
- package/dist/convex/crm/calendarCronDispatch.js +83 -0
- package/dist/convex/crm/calendarSync.js +294 -0
- package/dist/convex/crm/companies.js +323 -0
- package/dist/convex/crm/contacts.js +346 -0
- package/dist/convex/crm/deals.js +481 -0
- package/dist/convex/crm/emailActions.js +158 -0
- package/dist/convex/crm/emailCron.js +210 -0
- package/dist/convex/crm/emailCronDispatch.js +76 -0
- package/dist/convex/crm/emailSync.js +260 -0
- package/dist/convex/crm/onboarding.js +185 -0
- package/dist/convex/crm/stats.js +75 -0
- package/dist/convex/crm/tasks.js +109 -0
- package/dist/convex/crons.js +25 -0
- package/dist/convex/integrations.js +183 -0
- package/dist/convex/lib/auditLog.js +109 -0
- package/dist/convex/lib/auth.js +372 -0
- package/dist/convex/lib/rbac.js +123 -0
- package/dist/convex/lib/workspace.js +171 -0
- package/dist/convex/organizations.js +192 -0
- package/dist/convex/schema.js +690 -0
- package/dist/convex/users.js +217 -0
- package/dist/convex/workspaces.js +603 -0
- package/dist/mcp-server/lib/convexClient.js +50 -0
- package/dist/mcp-server/lib/scopeEnforcement.js +76 -0
- package/dist/mcp-server/registry.js +116 -0
- package/dist/mcp-server/server.js +97 -0
- package/dist/mcp-server/tests/registry.test.js +163 -0
- package/dist/mcp-server/tests/scopeEnforcement.test.js +137 -0
- package/dist/mcp-server/tests/security.test.js +257 -0
- package/dist/mcp-server/tests/tools.test.js +272 -0
- package/dist/mcp-server/tools/activities.js +207 -0
- package/dist/mcp-server/tools/admin.js +190 -0
- package/dist/mcp-server/tools/companies.js +233 -0
- package/dist/mcp-server/tools/contacts.js +306 -0
- package/dist/mcp-server/tools/customFields.js +222 -0
- package/dist/mcp-server/tools/customObjects.js +235 -0
- package/dist/mcp-server/tools/deals.js +297 -0
- package/dist/mcp-server/tools/rbac.js +177 -0
- package/dist/mcp-server/tools/search.js +155 -0
- package/dist/mcp-server/tools/workflows.js +234 -0
- package/dist/mcp-server/transport/http.js +257 -0
- package/dist/mcp-server/transport/stdio.js +90 -0
- package/package.json +45 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* CRM Email Cron — Gmail Sync Action
|
|
4
|
+
*
|
|
5
|
+
* Convex internalAction that fetches recent emails from Gmail via Composio REST API.
|
|
6
|
+
* Stores HTML bodies in file storage, upserts into emailSync table.
|
|
7
|
+
* Triggered every 5 minutes by crons.ts.
|
|
8
|
+
*
|
|
9
|
+
* NOTE: Convex actions cannot import from repo-root lib/, so we call
|
|
10
|
+
* Composio's REST API directly via fetch().
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.pullFromGmail = void 0;
|
|
14
|
+
const server_1 = require("../_generated/server");
|
|
15
|
+
const api_1 = require("../_generated/api");
|
|
16
|
+
const values_1 = require("convex/values");
|
|
17
|
+
// Composio REST API base URL
|
|
18
|
+
const COMPOSIO_API_BASE = 'https://backend.composio.dev/api/v2';
|
|
19
|
+
// =============================================================================
|
|
20
|
+
// PULL FROM GMAIL — Cron-triggered action
|
|
21
|
+
// =============================================================================
|
|
22
|
+
exports.pullFromGmail = (0, server_1.internalAction)({
|
|
23
|
+
args: {
|
|
24
|
+
workspaceId: values_1.v.id('workspaces'),
|
|
25
|
+
entityId: values_1.v.string(),
|
|
26
|
+
},
|
|
27
|
+
handler: async (ctx, args) => {
|
|
28
|
+
const apiKey = process.env.COMPOSIO_API_KEY;
|
|
29
|
+
if (!apiKey) {
|
|
30
|
+
console.error('[EmailCron] COMPOSIO_API_KEY not set');
|
|
31
|
+
return { synced: 0, error: 'No API key' };
|
|
32
|
+
}
|
|
33
|
+
console.log('[EmailCron] Starting Gmail sync for workspace:', args.workspaceId);
|
|
34
|
+
try {
|
|
35
|
+
// Step 1: List recent messages via Composio
|
|
36
|
+
const listResponse = await executeComposioAction(apiKey, args.entityId, {
|
|
37
|
+
action: 'GMAIL_LIST_MESSAGES',
|
|
38
|
+
params: {
|
|
39
|
+
max_results: 20,
|
|
40
|
+
label_ids: ['INBOX'],
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
const messages = extractMessages(listResponse);
|
|
44
|
+
if (!messages || messages.length === 0) {
|
|
45
|
+
console.log('[EmailCron] No messages returned from Gmail');
|
|
46
|
+
return { synced: 0 };
|
|
47
|
+
}
|
|
48
|
+
console.log('[EmailCron] Found', messages.length, 'messages to process');
|
|
49
|
+
let syncedCount = 0;
|
|
50
|
+
for (const msg of messages) {
|
|
51
|
+
try {
|
|
52
|
+
// Step 2: Get full message details
|
|
53
|
+
const detailResponse = await executeComposioAction(apiKey, args.entityId, {
|
|
54
|
+
action: 'GMAIL_GET_MESSAGE',
|
|
55
|
+
params: {
|
|
56
|
+
message_id: msg.id,
|
|
57
|
+
format: 'full',
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
const detail = extractMessageDetail(detailResponse);
|
|
61
|
+
if (!detail) {
|
|
62
|
+
console.warn('[EmailCron] Could not parse message:', msg.id);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
// Parse email fields
|
|
66
|
+
const headers = detail.payload?.headers ?? [];
|
|
67
|
+
const subject = getHeader(headers, 'Subject') || '(no subject)';
|
|
68
|
+
const from = getHeader(headers, 'From') || '';
|
|
69
|
+
const toRaw = getHeader(headers, 'To') || '';
|
|
70
|
+
const to = toRaw
|
|
71
|
+
.split(',')
|
|
72
|
+
.map((s) => s.trim())
|
|
73
|
+
.filter(Boolean);
|
|
74
|
+
// Extract body
|
|
75
|
+
const { plain } = extractBody(detail);
|
|
76
|
+
// Truncate plain text
|
|
77
|
+
const MAX_PLAIN = 10_000;
|
|
78
|
+
let bodyPlain = plain;
|
|
79
|
+
if (bodyPlain && bodyPlain.length > MAX_PLAIN) {
|
|
80
|
+
bodyPlain = bodyPlain.substring(0, MAX_PLAIN);
|
|
81
|
+
}
|
|
82
|
+
const snippet = detail.snippet || bodyPlain?.substring(0, 200);
|
|
83
|
+
// Determine sentAt from internalDate
|
|
84
|
+
const sentAt = detail.internalDate
|
|
85
|
+
? parseInt(detail.internalDate, 10)
|
|
86
|
+
: Date.now();
|
|
87
|
+
// Step 3: Upsert into emailSync table
|
|
88
|
+
await ctx.runMutation(api_1.internal.crm.emailSync.upsertFromSync, {
|
|
89
|
+
workspaceId: args.workspaceId,
|
|
90
|
+
gmailId: msg.id,
|
|
91
|
+
threadId: msg.threadId || msg.id,
|
|
92
|
+
subject,
|
|
93
|
+
snippet,
|
|
94
|
+
from,
|
|
95
|
+
to,
|
|
96
|
+
bodyPlain,
|
|
97
|
+
sentAt,
|
|
98
|
+
});
|
|
99
|
+
syncedCount++;
|
|
100
|
+
}
|
|
101
|
+
catch (emailError) {
|
|
102
|
+
console.error('[EmailCron] Failed to process message:', msg.id, emailError);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
console.log('[EmailCron] Synced', syncedCount, 'emails');
|
|
106
|
+
return { synced: syncedCount };
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
console.error('[EmailCron] Gmail sync failed:', error);
|
|
110
|
+
return { synced: 0, error: String(error) };
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
// =============================================================================
|
|
115
|
+
// COMPOSIO REST API HELPERS
|
|
116
|
+
// =============================================================================
|
|
117
|
+
async function executeComposioAction(apiKey, entityId, body) {
|
|
118
|
+
const response = await fetch(`${COMPOSIO_API_BASE}/actions/execute`, {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: {
|
|
121
|
+
'Content-Type': 'application/json',
|
|
122
|
+
'x-api-key': apiKey,
|
|
123
|
+
},
|
|
124
|
+
body: JSON.stringify({
|
|
125
|
+
actionName: body.action,
|
|
126
|
+
connectedAccountId: undefined,
|
|
127
|
+
entityId,
|
|
128
|
+
input: body.params,
|
|
129
|
+
}),
|
|
130
|
+
});
|
|
131
|
+
if (!response.ok) {
|
|
132
|
+
const text = await response.text();
|
|
133
|
+
throw new Error(`Composio API error (${response.status}): ${text}`);
|
|
134
|
+
}
|
|
135
|
+
return (await response.json());
|
|
136
|
+
}
|
|
137
|
+
function extractMessages(response) {
|
|
138
|
+
// Composio wraps responses in data.response_data
|
|
139
|
+
const data = response?.data?.response_data ?? response?.data ?? response;
|
|
140
|
+
if (Array.isArray(data)) {
|
|
141
|
+
return data;
|
|
142
|
+
}
|
|
143
|
+
// Sometimes nested under 'messages'
|
|
144
|
+
const obj = data;
|
|
145
|
+
if (obj?.messages && Array.isArray(obj.messages)) {
|
|
146
|
+
return obj.messages;
|
|
147
|
+
}
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
function extractMessageDetail(response) {
|
|
151
|
+
const data = response?.data?.response_data ?? response?.data ?? response;
|
|
152
|
+
if (data && typeof data === 'object' && 'id' in data) {
|
|
153
|
+
return data;
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
function getHeader(headers, name) {
|
|
158
|
+
return headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value;
|
|
159
|
+
}
|
|
160
|
+
function extractBody(detail) {
|
|
161
|
+
let plain;
|
|
162
|
+
let html;
|
|
163
|
+
const payload = detail.payload;
|
|
164
|
+
if (!payload)
|
|
165
|
+
return { plain, html };
|
|
166
|
+
// Single-part message
|
|
167
|
+
if (payload.body?.data) {
|
|
168
|
+
const decoded = decodeBase64Url(payload.body.data);
|
|
169
|
+
if (payload.mimeType === 'text/html') {
|
|
170
|
+
html = decoded;
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
plain = decoded;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Multi-part message
|
|
177
|
+
if (payload.parts) {
|
|
178
|
+
for (const part of payload.parts) {
|
|
179
|
+
if (part.mimeType === 'text/plain' && part.body?.data) {
|
|
180
|
+
plain = decodeBase64Url(part.body.data);
|
|
181
|
+
}
|
|
182
|
+
else if (part.mimeType === 'text/html' && part.body?.data) {
|
|
183
|
+
html = decodeBase64Url(part.body.data);
|
|
184
|
+
}
|
|
185
|
+
// Nested multipart (e.g., multipart/alternative inside multipart/mixed)
|
|
186
|
+
if (part.parts) {
|
|
187
|
+
for (const subpart of part.parts) {
|
|
188
|
+
if (subpart.mimeType === 'text/plain' && subpart.body?.data) {
|
|
189
|
+
plain = plain || decodeBase64Url(subpart.body.data);
|
|
190
|
+
}
|
|
191
|
+
else if (subpart.mimeType === 'text/html' &&
|
|
192
|
+
subpart.body?.data) {
|
|
193
|
+
html = html || decodeBase64Url(subpart.body.data);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { plain, html };
|
|
200
|
+
}
|
|
201
|
+
function decodeBase64Url(encoded) {
|
|
202
|
+
// Gmail API uses URL-safe base64 encoding
|
|
203
|
+
const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/');
|
|
204
|
+
try {
|
|
205
|
+
return Buffer.from(base64, 'base64').toString('utf-8');
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return encoded;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Email Cron Dispatcher
|
|
4
|
+
*
|
|
5
|
+
* Finds all workspaces with Gmail sync enabled and triggers
|
|
6
|
+
* sync for each one. Mirrors calendarCronDispatch.ts pattern.
|
|
7
|
+
*
|
|
8
|
+
* The cron calls this dispatcher every 5 minutes.
|
|
9
|
+
* The dispatcher fans out to per-workspace sync actions.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.getEmailSyncWorkspaces = exports.dispatchEmailSync = void 0;
|
|
13
|
+
const server_1 = require("../_generated/server");
|
|
14
|
+
const api_1 = require("../_generated/api");
|
|
15
|
+
// =============================================================================
|
|
16
|
+
// DISPATCH — find workspaces and fan out sync actions
|
|
17
|
+
// =============================================================================
|
|
18
|
+
exports.dispatchEmailSync = (0, server_1.internalAction)({
|
|
19
|
+
args: {},
|
|
20
|
+
handler: async (ctx) => {
|
|
21
|
+
console.log('[EmailCronDispatch] Starting dispatch...');
|
|
22
|
+
// Get all workspaces with email sync enabled
|
|
23
|
+
const workspaces = await ctx.runQuery(api_1.internal.crm.emailCronDispatch.getEmailSyncWorkspaces);
|
|
24
|
+
if (workspaces.length === 0) {
|
|
25
|
+
console.log('[EmailCronDispatch] No workspaces with email sync enabled');
|
|
26
|
+
return { dispatched: 0 };
|
|
27
|
+
}
|
|
28
|
+
console.log('[EmailCronDispatch] Dispatching sync for', workspaces.length, 'workspaces');
|
|
29
|
+
let dispatched = 0;
|
|
30
|
+
for (const ws of workspaces) {
|
|
31
|
+
try {
|
|
32
|
+
await ctx.runAction(api_1.internal.crm.emailCron.pullFromGmail, {
|
|
33
|
+
workspaceId: ws.workspaceId,
|
|
34
|
+
entityId: ws.entityId,
|
|
35
|
+
});
|
|
36
|
+
dispatched++;
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
console.error('[EmailCronDispatch] Failed for workspace:', ws.workspaceId, error);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
console.log('[EmailCronDispatch] Dispatched', dispatched, 'sync jobs');
|
|
43
|
+
return { dispatched };
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
// =============================================================================
|
|
47
|
+
// QUERY — get workspaces with email sync enabled
|
|
48
|
+
// =============================================================================
|
|
49
|
+
exports.getEmailSyncWorkspaces = (0, server_1.internalQuery)({
|
|
50
|
+
args: {},
|
|
51
|
+
handler: async (ctx) => {
|
|
52
|
+
const workspaces = await ctx.db.query('workspaces').collect();
|
|
53
|
+
const results = [];
|
|
54
|
+
for (const ws of workspaces) {
|
|
55
|
+
// Only sync workspaces that have email sync enabled
|
|
56
|
+
const settings = ws.settings;
|
|
57
|
+
if (!settings?.emailSyncEnabled)
|
|
58
|
+
continue;
|
|
59
|
+
// Determine entityId
|
|
60
|
+
let entityId;
|
|
61
|
+
if (ws.ownerType === 'user' && ws.ownerId) {
|
|
62
|
+
const user = await ctx.db.get(ws.ownerId);
|
|
63
|
+
if (user && 'clerkId' in user) {
|
|
64
|
+
entityId = user.clerkId;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
else if (ws.ownerType === 'organization' && ws.orgId) {
|
|
68
|
+
entityId = ws.orgId;
|
|
69
|
+
}
|
|
70
|
+
if (entityId) {
|
|
71
|
+
results.push({ workspaceId: ws._id, entityId });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return results;
|
|
75
|
+
},
|
|
76
|
+
});
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* CRM Email Sync — Mutations & Queries
|
|
4
|
+
*
|
|
5
|
+
* Syncs Gmail messages into the emailSync table via Composio.
|
|
6
|
+
* Auto-links to contacts (by email) and companies (by domain).
|
|
7
|
+
* V0.1.0: simplified schema — sentAt replaces receivedAt, cc/isRead/isStarred/hasAttachments removed.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.internalGenerateUploadUrl = exports.generateUploadUrl = exports.list = exports.getByGmailId = exports.search = exports.linkToContact = exports.upsertFromSync = void 0;
|
|
11
|
+
const server_1 = require("../_generated/server");
|
|
12
|
+
const values_1 = require("convex/values");
|
|
13
|
+
const workspace_1 = require("../lib/workspace");
|
|
14
|
+
// =============================================================================
|
|
15
|
+
// CONSTANTS
|
|
16
|
+
// =============================================================================
|
|
17
|
+
const MAX_BODY_PLAIN_LENGTH = 10_000; // 10KB truncation limit
|
|
18
|
+
const SNIPPET_LENGTH = 200;
|
|
19
|
+
// =============================================================================
|
|
20
|
+
// SYNC EMAILS (upsert by gmailId)
|
|
21
|
+
// =============================================================================
|
|
22
|
+
/**
|
|
23
|
+
* Internal mutation used by the cron action to upsert synced emails.
|
|
24
|
+
* Skips auth since cron runs server-side.
|
|
25
|
+
* OQ-1: actorId='agent:composio' tracked in activities created from this sync.
|
|
26
|
+
*/
|
|
27
|
+
exports.upsertFromSync = (0, server_1.internalMutation)({
|
|
28
|
+
args: {
|
|
29
|
+
workspaceId: values_1.v.id('workspaces'),
|
|
30
|
+
gmailId: values_1.v.string(),
|
|
31
|
+
threadId: values_1.v.optional(values_1.v.string()),
|
|
32
|
+
subject: values_1.v.string(),
|
|
33
|
+
snippet: values_1.v.optional(values_1.v.string()),
|
|
34
|
+
from: values_1.v.string(),
|
|
35
|
+
to: values_1.v.optional(values_1.v.array(values_1.v.string())),
|
|
36
|
+
bodyPlain: values_1.v.optional(values_1.v.string()),
|
|
37
|
+
sentAt: values_1.v.number(),
|
|
38
|
+
},
|
|
39
|
+
returns: values_1.v.id('emailSync'),
|
|
40
|
+
handler: async (ctx, args) => {
|
|
41
|
+
// Check if email already synced
|
|
42
|
+
const existing = await ctx.db
|
|
43
|
+
.query('emailSync')
|
|
44
|
+
.withIndex('by_gmail_id', (q) => q.eq('gmailId', args.gmailId))
|
|
45
|
+
.first();
|
|
46
|
+
if (existing) {
|
|
47
|
+
return existing._id;
|
|
48
|
+
}
|
|
49
|
+
// Truncate bodyPlain
|
|
50
|
+
let bodyPlain = args.bodyPlain;
|
|
51
|
+
if (bodyPlain && bodyPlain.length > MAX_BODY_PLAIN_LENGTH) {
|
|
52
|
+
bodyPlain = bodyPlain.substring(0, MAX_BODY_PLAIN_LENGTH);
|
|
53
|
+
}
|
|
54
|
+
// Generate snippet from bodyPlain if not provided
|
|
55
|
+
const snippet = args.snippet ||
|
|
56
|
+
(bodyPlain ? bodyPlain.substring(0, SNIPPET_LENGTH) : undefined);
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
// Auto-link: match from email against contacts
|
|
59
|
+
const fromEmail = extractEmail(args.from);
|
|
60
|
+
let matchedContactId;
|
|
61
|
+
let matchedCompanyId;
|
|
62
|
+
if (fromEmail) {
|
|
63
|
+
const contact = await ctx.db
|
|
64
|
+
.query('contacts')
|
|
65
|
+
.withIndex('by_workspace', (q) => q.eq('workspaceId', args.workspaceId))
|
|
66
|
+
.filter((q) => q.eq(q.field('email'), fromEmail))
|
|
67
|
+
.first();
|
|
68
|
+
if (contact) {
|
|
69
|
+
matchedContactId = contact._id;
|
|
70
|
+
if (contact.companyId) {
|
|
71
|
+
matchedCompanyId = contact.companyId;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
// Try by domain
|
|
76
|
+
const domain = extractDomain(fromEmail);
|
|
77
|
+
if (domain) {
|
|
78
|
+
const company = await ctx.db
|
|
79
|
+
.query('companies')
|
|
80
|
+
.withIndex('by_domain', (q) => q.eq('domain', domain))
|
|
81
|
+
.filter((q) => q.eq(q.field('workspaceId'), args.workspaceId))
|
|
82
|
+
.first();
|
|
83
|
+
if (company) {
|
|
84
|
+
matchedCompanyId = company._id;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const emailId = await ctx.db.insert('emailSync', {
|
|
90
|
+
gmailId: args.gmailId,
|
|
91
|
+
threadId: args.threadId,
|
|
92
|
+
subject: args.subject,
|
|
93
|
+
snippet,
|
|
94
|
+
from: args.from,
|
|
95
|
+
to: args.to,
|
|
96
|
+
bodyPlain,
|
|
97
|
+
contactId: matchedContactId,
|
|
98
|
+
companyId: matchedCompanyId,
|
|
99
|
+
workspaceId: args.workspaceId,
|
|
100
|
+
syncedAt: now,
|
|
101
|
+
sentAt: args.sentAt,
|
|
102
|
+
});
|
|
103
|
+
return emailId;
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
// =============================================================================
|
|
107
|
+
// LINK TO CONTACT (manual linking)
|
|
108
|
+
// =============================================================================
|
|
109
|
+
exports.linkToContact = (0, server_1.mutation)({
|
|
110
|
+
args: {
|
|
111
|
+
emailId: values_1.v.id('emailSync'),
|
|
112
|
+
contactId: values_1.v.optional(values_1.v.id('contacts')),
|
|
113
|
+
companyId: values_1.v.optional(values_1.v.id('companies')),
|
|
114
|
+
},
|
|
115
|
+
returns: values_1.v.id('emailSync'),
|
|
116
|
+
handler: async (ctx, args) => {
|
|
117
|
+
const email = await ctx.db.get(args.emailId);
|
|
118
|
+
if (!email)
|
|
119
|
+
throw new Error('Email not found');
|
|
120
|
+
const { canWrite } = await (0, workspace_1.validateWorkspaceAccess)(ctx, email.workspaceId);
|
|
121
|
+
if (!canWrite)
|
|
122
|
+
throw new Error('Insufficient permissions');
|
|
123
|
+
await ctx.db.patch(args.emailId, {
|
|
124
|
+
...(args.contactId !== undefined && { contactId: args.contactId }),
|
|
125
|
+
...(args.companyId !== undefined && { companyId: args.companyId }),
|
|
126
|
+
});
|
|
127
|
+
return args.emailId;
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
// =============================================================================
|
|
131
|
+
// SEARCH
|
|
132
|
+
// =============================================================================
|
|
133
|
+
exports.search = (0, server_1.query)({
|
|
134
|
+
args: {
|
|
135
|
+
workspaceId: values_1.v.id('workspaces'),
|
|
136
|
+
contactId: values_1.v.optional(values_1.v.id('contacts')),
|
|
137
|
+
subject: values_1.v.optional(values_1.v.string()),
|
|
138
|
+
dateFrom: values_1.v.optional(values_1.v.number()),
|
|
139
|
+
dateTo: values_1.v.optional(values_1.v.number()),
|
|
140
|
+
limit: values_1.v.optional(values_1.v.number()),
|
|
141
|
+
},
|
|
142
|
+
returns: values_1.v.array(values_1.v.any()),
|
|
143
|
+
handler: async (ctx, args) => {
|
|
144
|
+
await (0, workspace_1.validateWorkspaceAccess)(ctx, args.workspaceId);
|
|
145
|
+
const limit = args.limit ?? 20;
|
|
146
|
+
if (args.contactId) {
|
|
147
|
+
const results = await ctx.db
|
|
148
|
+
.query('emailSync')
|
|
149
|
+
.withIndex('by_contact', (q) => q.eq('contactId', args.contactId))
|
|
150
|
+
.order('desc')
|
|
151
|
+
.take(limit + 20);
|
|
152
|
+
return filterEmails(results, args).slice(0, limit);
|
|
153
|
+
}
|
|
154
|
+
const allResults = await ctx.db
|
|
155
|
+
.query('emailSync')
|
|
156
|
+
.withIndex('by_workspace_sent', (q) => q.eq('workspaceId', args.workspaceId))
|
|
157
|
+
.order('desc')
|
|
158
|
+
.take(limit * 5);
|
|
159
|
+
return filterEmails(allResults, args).slice(0, limit);
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
// =============================================================================
|
|
163
|
+
// GET BY GMAIL ID (for dedup check from actions)
|
|
164
|
+
// =============================================================================
|
|
165
|
+
exports.getByGmailId = (0, server_1.query)({
|
|
166
|
+
args: {
|
|
167
|
+
gmailId: values_1.v.string(),
|
|
168
|
+
},
|
|
169
|
+
returns: values_1.v.union(values_1.v.null(), values_1.v.any()),
|
|
170
|
+
handler: async (ctx, args) => {
|
|
171
|
+
return await ctx.db
|
|
172
|
+
.query('emailSync')
|
|
173
|
+
.withIndex('by_gmail_id', (q) => q.eq('gmailId', args.gmailId))
|
|
174
|
+
.first();
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
// =============================================================================
|
|
178
|
+
// LIST (for a workspace, ordered by sentAt)
|
|
179
|
+
// =============================================================================
|
|
180
|
+
exports.list = (0, server_1.query)({
|
|
181
|
+
args: {
|
|
182
|
+
workspaceId: values_1.v.id('workspaces'),
|
|
183
|
+
limit: values_1.v.optional(values_1.v.number()),
|
|
184
|
+
},
|
|
185
|
+
returns: values_1.v.array(values_1.v.any()),
|
|
186
|
+
handler: async (ctx, args) => {
|
|
187
|
+
await (0, workspace_1.validateWorkspaceAccess)(ctx, args.workspaceId);
|
|
188
|
+
const limit = args.limit ?? 20;
|
|
189
|
+
return await ctx.db
|
|
190
|
+
.query('emailSync')
|
|
191
|
+
.withIndex('by_workspace_sent', (q) => q.eq('workspaceId', args.workspaceId))
|
|
192
|
+
.order('desc')
|
|
193
|
+
.take(limit);
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
// =============================================================================
|
|
197
|
+
// GENERATE UPLOAD URL (for storing email HTML bodies in file storage)
|
|
198
|
+
// =============================================================================
|
|
199
|
+
exports.generateUploadUrl = (0, server_1.mutation)({
|
|
200
|
+
args: {},
|
|
201
|
+
returns: values_1.v.string(),
|
|
202
|
+
handler: async (ctx) => {
|
|
203
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
204
|
+
if (!identity)
|
|
205
|
+
throw new Error('Unauthorized');
|
|
206
|
+
return await ctx.storage.generateUploadUrl();
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
exports.internalGenerateUploadUrl = (0, server_1.internalMutation)({
|
|
210
|
+
args: {},
|
|
211
|
+
returns: values_1.v.string(),
|
|
212
|
+
handler: async (ctx) => {
|
|
213
|
+
return await ctx.storage.generateUploadUrl();
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
// =============================================================================
|
|
217
|
+
// HELPERS
|
|
218
|
+
// =============================================================================
|
|
219
|
+
function extractEmail(fromHeader) {
|
|
220
|
+
const match = fromHeader.match(/<([^>]+)>/);
|
|
221
|
+
if (match)
|
|
222
|
+
return match[1].toLowerCase();
|
|
223
|
+
if (fromHeader.includes('@'))
|
|
224
|
+
return fromHeader.trim().toLowerCase();
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
function extractDomain(email) {
|
|
228
|
+
const parts = email.split('@');
|
|
229
|
+
if (parts.length !== 2)
|
|
230
|
+
return null;
|
|
231
|
+
const domain = parts[1].toLowerCase();
|
|
232
|
+
const genericDomains = [
|
|
233
|
+
'gmail.com',
|
|
234
|
+
'yahoo.com',
|
|
235
|
+
'hotmail.com',
|
|
236
|
+
'outlook.com',
|
|
237
|
+
'live.com',
|
|
238
|
+
'icloud.com',
|
|
239
|
+
'protonmail.com',
|
|
240
|
+
'aol.com',
|
|
241
|
+
'mail.com',
|
|
242
|
+
];
|
|
243
|
+
if (genericDomains.includes(domain))
|
|
244
|
+
return null;
|
|
245
|
+
return domain;
|
|
246
|
+
}
|
|
247
|
+
function filterEmails(results, args) {
|
|
248
|
+
let filtered = results;
|
|
249
|
+
if (args.subject) {
|
|
250
|
+
const subjectLower = args.subject.toLowerCase();
|
|
251
|
+
filtered = filtered.filter((e) => e.subject.toLowerCase().includes(subjectLower));
|
|
252
|
+
}
|
|
253
|
+
if (args.dateFrom) {
|
|
254
|
+
filtered = filtered.filter((e) => e.sentAt >= args.dateFrom);
|
|
255
|
+
}
|
|
256
|
+
if (args.dateTo) {
|
|
257
|
+
filtered = filtered.filter((e) => e.sentAt <= args.dateTo);
|
|
258
|
+
}
|
|
259
|
+
return filtered;
|
|
260
|
+
}
|