@niama/loops 0.2.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 +506 -0
- package/dist/client/index.d.ts +510 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +464 -0
- package/dist/component/_generated/api.d.ts +232 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +30 -0
- package/dist/component/_generated/component.d.ts +245 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +9 -0
- package/dist/component/_generated/dataModel.d.ts +46 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +10 -0
- package/dist/component/_generated/server.d.ts +121 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +77 -0
- package/dist/component/actions.d.ts +159 -0
- package/dist/component/actions.d.ts.map +1 -0
- package/dist/component/actions.js +468 -0
- package/dist/component/aggregates.d.ts +42 -0
- package/dist/component/aggregates.d.ts.map +1 -0
- package/dist/component/aggregates.js +54 -0
- package/dist/component/convex.config.d.ts +3 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +5 -0
- package/dist/component/helpers.d.ts +16 -0
- package/dist/component/helpers.d.ts.map +1 -0
- package/dist/component/helpers.js +98 -0
- package/dist/component/http.d.ts +3 -0
- package/dist/component/http.d.ts.map +1 -0
- package/dist/component/http.js +208 -0
- package/dist/component/mutations.d.ts +55 -0
- package/dist/component/mutations.d.ts.map +1 -0
- package/dist/component/mutations.js +167 -0
- package/dist/component/queries.d.ts +171 -0
- package/dist/component/queries.d.ts.map +1 -0
- package/dist/component/queries.js +516 -0
- package/dist/component/schema.d.ts +63 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +16 -0
- package/dist/component/tables/contacts.d.ts +16 -0
- package/dist/component/tables/contacts.d.ts.map +1 -0
- package/dist/component/tables/contacts.js +16 -0
- package/dist/component/tables/emailOperations.d.ts +17 -0
- package/dist/component/tables/emailOperations.d.ts.map +1 -0
- package/dist/component/tables/emailOperations.js +17 -0
- package/dist/component/validators.d.ts +338 -0
- package/dist/component/validators.d.ts.map +1 -0
- package/dist/component/validators.js +167 -0
- package/dist/test.d.ts +78 -0
- package/dist/test.d.ts.map +1 -0
- package/dist/test.js +16 -0
- package/dist/types.d.ts +39 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +0 -0
- package/package.json +112 -0
- package/src/client/index.ts +618 -0
- package/src/component/_generated/api.ts +253 -0
- package/src/component/_generated/component.ts +291 -0
- package/src/component/_generated/dataModel.ts +60 -0
- package/src/component/_generated/server.ts +161 -0
- package/src/component/actions.ts +556 -0
- package/src/component/aggregates.ts +89 -0
- package/src/component/convex.config.ts +8 -0
- package/src/component/helpers.ts +130 -0
- package/src/component/http.ts +236 -0
- package/src/component/mutations.ts +192 -0
- package/src/component/queries.ts +604 -0
- package/src/component/schema.ts +17 -0
- package/src/component/tables/contacts.ts +17 -0
- package/src/component/tables/emailOperations.ts +23 -0
- package/src/component/validators.ts +197 -0
- package/src/test.ts +27 -0
- package/src/types.ts +62 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
import type { PaginationResult } from "convex/server";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
import { paginator } from "convex-helpers/server/pagination";
|
|
4
|
+
import type { Doc } from "./_generated/dataModel";
|
|
5
|
+
import { query } from "./_generated/server";
|
|
6
|
+
import { aggregateCountByUserGroup, aggregateCountTotal } from "./aggregates";
|
|
7
|
+
import schema from "./schema";
|
|
8
|
+
import {
|
|
9
|
+
actorSpamValidator,
|
|
10
|
+
emailStatsResponseValidator,
|
|
11
|
+
paginatedContactsResponseValidator,
|
|
12
|
+
rapidFirePatternValidator,
|
|
13
|
+
rateLimitResponseValidator,
|
|
14
|
+
recipientSpamValidator,
|
|
15
|
+
} from "./validators";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Maximum number of documents to read when counting with filters.
|
|
19
|
+
* This limit prevents query read limit errors while still providing accurate
|
|
20
|
+
* counts for most use cases. If you have more contacts than this, consider
|
|
21
|
+
* using the aggregate-based counting with userGroup only.
|
|
22
|
+
*/
|
|
23
|
+
const MAX_COUNT_LIMIT = 8000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Maximum number of email operations to read for spam detection.
|
|
27
|
+
* This limit prevents query read limit errors while covering most spam scenarios.
|
|
28
|
+
* If you need to analyze more operations, consider using scheduled jobs with pagination.
|
|
29
|
+
*/
|
|
30
|
+
const MAX_SPAM_DETECTION_LIMIT = 8000;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Count contacts in the database
|
|
34
|
+
* Can filter by audience criteria (userGroup, source, subscribed status)
|
|
35
|
+
*
|
|
36
|
+
* For userGroup-only filtering, uses efficient O(log n) aggregate counting.
|
|
37
|
+
* For other filters (source, subscribed), uses indexed queries with a read limit.
|
|
38
|
+
*
|
|
39
|
+
* IMPORTANT: Before using this with existing data, run the backfillContactAggregate
|
|
40
|
+
* mutation to populate the aggregate with existing contacts.
|
|
41
|
+
*
|
|
42
|
+
* NOTE: When filtering by source or subscribed, counts are capped at MAX_COUNT_LIMIT
|
|
43
|
+
* to avoid query read limit errors. For exact counts with large datasets, use
|
|
44
|
+
* userGroup-only filtering which uses efficient aggregate counting.
|
|
45
|
+
*/
|
|
46
|
+
export const countContacts = query({
|
|
47
|
+
args: {
|
|
48
|
+
userGroup: v.optional(v.string()),
|
|
49
|
+
source: v.optional(v.string()),
|
|
50
|
+
subscribed: v.optional(v.boolean()),
|
|
51
|
+
},
|
|
52
|
+
returns: v.number(),
|
|
53
|
+
handler: async (ctx, args) => {
|
|
54
|
+
// If only userGroup is specified (or no filters), use efficient aggregate counting
|
|
55
|
+
const onlyUserGroupFilter =
|
|
56
|
+
args.source === undefined && args.subscribed === undefined;
|
|
57
|
+
|
|
58
|
+
if (onlyUserGroupFilter) {
|
|
59
|
+
// Use O(log n) aggregate counting - much more efficient than .collect()
|
|
60
|
+
if (args.userGroup === undefined) {
|
|
61
|
+
// Count ALL contacts across all namespaces
|
|
62
|
+
return await aggregateCountTotal(ctx);
|
|
63
|
+
}
|
|
64
|
+
// Count contacts in specific userGroup namespace
|
|
65
|
+
return await aggregateCountByUserGroup(ctx, args.userGroup);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// For other filters, we need to use indexed queries with in-memory filtering
|
|
69
|
+
// We use .take() with a reasonable limit to avoid query read limit errors
|
|
70
|
+
let contacts: Doc<"contacts">[];
|
|
71
|
+
|
|
72
|
+
if (args.userGroup !== undefined) {
|
|
73
|
+
contacts = await ctx.db
|
|
74
|
+
.query("contacts")
|
|
75
|
+
.withIndex("userGroup", (q) => q.eq("userGroup", args.userGroup))
|
|
76
|
+
.take(MAX_COUNT_LIMIT);
|
|
77
|
+
} else if (args.source !== undefined) {
|
|
78
|
+
contacts = await ctx.db
|
|
79
|
+
.query("contacts")
|
|
80
|
+
.withIndex("source", (q) => q.eq("source", args.source))
|
|
81
|
+
.take(MAX_COUNT_LIMIT);
|
|
82
|
+
} else if (args.subscribed !== undefined) {
|
|
83
|
+
contacts = await ctx.db
|
|
84
|
+
.query("contacts")
|
|
85
|
+
.withIndex("subscribed", (q) => q.eq("subscribed", args.subscribed))
|
|
86
|
+
.take(MAX_COUNT_LIMIT);
|
|
87
|
+
} else {
|
|
88
|
+
// This branch shouldn't be reached due to onlyUserGroupFilter check above
|
|
89
|
+
contacts = await ctx.db.query("contacts").take(MAX_COUNT_LIMIT);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Apply additional filters if multiple criteria were provided
|
|
93
|
+
const filtered = contacts.filter((c) => {
|
|
94
|
+
if (args.userGroup !== undefined && c.userGroup !== args.userGroup) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
if (args.source !== undefined && c.source !== args.source) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
if (args.subscribed !== undefined && c.subscribed !== args.subscribed) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return filtered.length;
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* List contacts from the database with cursor-based pagination
|
|
112
|
+
* Can filter by audience criteria (userGroup, source, subscribed status)
|
|
113
|
+
* Returns actual contact data, not just a count
|
|
114
|
+
*
|
|
115
|
+
* Uses cursor-based pagination for efficient querying - only reads documents
|
|
116
|
+
* from the cursor position forward, not all preceding documents.
|
|
117
|
+
*
|
|
118
|
+
* Note: When multiple filters are provided, only one index can be used.
|
|
119
|
+
* Additional filters are applied in-memory after fetching.
|
|
120
|
+
*/
|
|
121
|
+
export const listContacts = query({
|
|
122
|
+
args: {
|
|
123
|
+
userGroup: v.optional(v.string()),
|
|
124
|
+
source: v.optional(v.string()),
|
|
125
|
+
subscribed: v.optional(v.boolean()),
|
|
126
|
+
limit: v.optional(v.number()),
|
|
127
|
+
cursor: v.optional(v.union(v.string(), v.null())),
|
|
128
|
+
},
|
|
129
|
+
returns: paginatedContactsResponseValidator,
|
|
130
|
+
handler: async (ctx, args) => {
|
|
131
|
+
const limit = Math.min(Math.max(1, args.limit ?? 100), 1000);
|
|
132
|
+
const paginationOpts = {
|
|
133
|
+
cursor: args.cursor ?? null,
|
|
134
|
+
numItems: limit,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Determine which index to use based on filters
|
|
138
|
+
const needsFiltering =
|
|
139
|
+
(args.userGroup !== undefined ? 1 : 0) +
|
|
140
|
+
(args.source !== undefined ? 1 : 0) +
|
|
141
|
+
(args.subscribed !== undefined ? 1 : 0) >
|
|
142
|
+
1;
|
|
143
|
+
|
|
144
|
+
let result: PaginationResult<Doc<"contacts">>;
|
|
145
|
+
|
|
146
|
+
if (args.userGroup !== undefined) {
|
|
147
|
+
result = await paginator(ctx.db, schema)
|
|
148
|
+
.query("contacts")
|
|
149
|
+
.withIndex("userGroup", (q) => q.eq("userGroup", args.userGroup))
|
|
150
|
+
.order("desc")
|
|
151
|
+
.paginate(paginationOpts);
|
|
152
|
+
} else if (args.source !== undefined) {
|
|
153
|
+
result = await paginator(ctx.db, schema)
|
|
154
|
+
.query("contacts")
|
|
155
|
+
.withIndex("source", (q) => q.eq("source", args.source))
|
|
156
|
+
.order("desc")
|
|
157
|
+
.paginate(paginationOpts);
|
|
158
|
+
} else if (args.subscribed !== undefined) {
|
|
159
|
+
result = await paginator(ctx.db, schema)
|
|
160
|
+
.query("contacts")
|
|
161
|
+
.withIndex("subscribed", (q) => q.eq("subscribed", args.subscribed))
|
|
162
|
+
.order("desc")
|
|
163
|
+
.paginate(paginationOpts);
|
|
164
|
+
} else {
|
|
165
|
+
result = await paginator(ctx.db, schema)
|
|
166
|
+
.query("contacts")
|
|
167
|
+
.order("desc")
|
|
168
|
+
.paginate(paginationOpts);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let contacts = result.page;
|
|
172
|
+
|
|
173
|
+
// Apply additional filters if multiple criteria were provided
|
|
174
|
+
if (needsFiltering) {
|
|
175
|
+
contacts = contacts.filter((c) => {
|
|
176
|
+
if (args.userGroup !== undefined && c.userGroup !== args.userGroup) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
if (args.source !== undefined && c.source !== args.source) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
if (args.subscribed !== undefined && c.subscribed !== args.subscribed) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
return true;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const mappedContacts = contacts.map((contact) => ({
|
|
190
|
+
_id: contact._id as unknown as string,
|
|
191
|
+
email: contact.email,
|
|
192
|
+
firstName: contact.firstName,
|
|
193
|
+
lastName: contact.lastName,
|
|
194
|
+
userId: contact.userId,
|
|
195
|
+
source: contact.source,
|
|
196
|
+
subscribed: contact.subscribed ?? true,
|
|
197
|
+
userGroup: contact.userGroup,
|
|
198
|
+
loopsContactId: contact.loopsContactId,
|
|
199
|
+
createdAt: contact.createdAt,
|
|
200
|
+
updatedAt: contact.updatedAt,
|
|
201
|
+
}));
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
contacts: mappedContacts,
|
|
205
|
+
continueCursor: result.continueCursor,
|
|
206
|
+
isDone: result.isDone,
|
|
207
|
+
};
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Check for spam patterns: too many emails to the same recipient in a time window
|
|
213
|
+
* Returns email addresses that received too many emails.
|
|
214
|
+
*
|
|
215
|
+
* NOTE: Analysis is limited to the most recent MAX_SPAM_DETECTION_LIMIT operations
|
|
216
|
+
* in the time window to avoid query read limit errors.
|
|
217
|
+
*/
|
|
218
|
+
export const detectRecipientSpam = query({
|
|
219
|
+
args: {
|
|
220
|
+
timeWindowMs: v.optional(v.number()),
|
|
221
|
+
maxEmailsPerRecipient: v.optional(v.number()),
|
|
222
|
+
},
|
|
223
|
+
returns: v.array(recipientSpamValidator),
|
|
224
|
+
handler: async (ctx, args) => {
|
|
225
|
+
const timeWindowMs = args.timeWindowMs ?? 3600000;
|
|
226
|
+
const maxEmailsPerRecipient = args.maxEmailsPerRecipient ?? 10;
|
|
227
|
+
const cutoffTime = Date.now() - timeWindowMs;
|
|
228
|
+
|
|
229
|
+
const operations = await ctx.db
|
|
230
|
+
.query("emailOperations")
|
|
231
|
+
.withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
|
|
232
|
+
.take(MAX_SPAM_DETECTION_LIMIT);
|
|
233
|
+
|
|
234
|
+
const emailCounts = new Map<string, number>();
|
|
235
|
+
for (const op of operations) {
|
|
236
|
+
if (op.email && op.email !== "audience") {
|
|
237
|
+
emailCounts.set(op.email, (emailCounts.get(op.email) ?? 0) + 1);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const suspicious: Array<{
|
|
242
|
+
email: string;
|
|
243
|
+
count: number;
|
|
244
|
+
timeWindowMs: number;
|
|
245
|
+
}> = [];
|
|
246
|
+
for (const [email, count] of emailCounts.entries()) {
|
|
247
|
+
if (count > maxEmailsPerRecipient) {
|
|
248
|
+
suspicious.push({
|
|
249
|
+
email,
|
|
250
|
+
count,
|
|
251
|
+
timeWindowMs,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return suspicious;
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Check for spam patterns: too many emails from the same actor/user
|
|
262
|
+
* Returns actor IDs that sent too many emails.
|
|
263
|
+
*
|
|
264
|
+
* NOTE: Analysis is limited to the most recent MAX_SPAM_DETECTION_LIMIT operations
|
|
265
|
+
* in the time window to avoid query read limit errors.
|
|
266
|
+
*/
|
|
267
|
+
export const detectActorSpam = query({
|
|
268
|
+
args: {
|
|
269
|
+
timeWindowMs: v.optional(v.number()),
|
|
270
|
+
maxEmailsPerActor: v.optional(v.number()),
|
|
271
|
+
},
|
|
272
|
+
returns: v.array(actorSpamValidator),
|
|
273
|
+
handler: async (ctx, args) => {
|
|
274
|
+
const timeWindowMs = args.timeWindowMs ?? 3600000;
|
|
275
|
+
const maxEmailsPerActor = args.maxEmailsPerActor ?? 100;
|
|
276
|
+
const cutoffTime = Date.now() - timeWindowMs;
|
|
277
|
+
|
|
278
|
+
const operations = await ctx.db
|
|
279
|
+
.query("emailOperations")
|
|
280
|
+
.withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
|
|
281
|
+
.take(MAX_SPAM_DETECTION_LIMIT);
|
|
282
|
+
|
|
283
|
+
const actorCounts = new Map<string, number>();
|
|
284
|
+
for (const op of operations) {
|
|
285
|
+
if (op.actorId) {
|
|
286
|
+
actorCounts.set(op.actorId, (actorCounts.get(op.actorId) ?? 0) + 1);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const suspicious: Array<{
|
|
291
|
+
actorId: string;
|
|
292
|
+
count: number;
|
|
293
|
+
timeWindowMs: number;
|
|
294
|
+
}> = [];
|
|
295
|
+
for (const [actorId, count] of actorCounts.entries()) {
|
|
296
|
+
if (count > maxEmailsPerActor) {
|
|
297
|
+
suspicious.push({
|
|
298
|
+
actorId,
|
|
299
|
+
count,
|
|
300
|
+
timeWindowMs,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return suspicious;
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Get recent email operation statistics for monitoring.
|
|
311
|
+
*
|
|
312
|
+
* NOTE: Statistics are calculated from the most recent MAX_SPAM_DETECTION_LIMIT
|
|
313
|
+
* operations in the time window to avoid query read limit errors. For high-volume
|
|
314
|
+
* applications, consider using scheduled jobs with pagination for exact statistics.
|
|
315
|
+
*/
|
|
316
|
+
export const getEmailStats = query({
|
|
317
|
+
args: {
|
|
318
|
+
timeWindowMs: v.optional(v.number()),
|
|
319
|
+
},
|
|
320
|
+
returns: emailStatsResponseValidator,
|
|
321
|
+
handler: async (ctx, args) => {
|
|
322
|
+
const timeWindowMs = args.timeWindowMs ?? 86400000;
|
|
323
|
+
const cutoffTime = Date.now() - timeWindowMs;
|
|
324
|
+
|
|
325
|
+
const operations = await ctx.db
|
|
326
|
+
.query("emailOperations")
|
|
327
|
+
.withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
|
|
328
|
+
.take(MAX_SPAM_DETECTION_LIMIT);
|
|
329
|
+
|
|
330
|
+
const stats = {
|
|
331
|
+
totalOperations: operations.length,
|
|
332
|
+
successfulOperations: operations.filter((op) => op.success).length,
|
|
333
|
+
failedOperations: operations.filter((op) => !op.success).length,
|
|
334
|
+
operationsByType: {} as Record<string, number>,
|
|
335
|
+
uniqueRecipients: new Set<string>(),
|
|
336
|
+
uniqueActors: new Set<string>(),
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
for (const op of operations) {
|
|
340
|
+
stats.operationsByType[op.operationType] =
|
|
341
|
+
(stats.operationsByType[op.operationType] ?? 0) + 1;
|
|
342
|
+
|
|
343
|
+
if (op.email && op.email !== "audience") {
|
|
344
|
+
stats.uniqueRecipients.add(op.email);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (op.actorId) {
|
|
348
|
+
stats.uniqueActors.add(op.actorId);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
...stats,
|
|
354
|
+
uniqueRecipients: stats.uniqueRecipients.size,
|
|
355
|
+
uniqueActors: stats.uniqueActors.size,
|
|
356
|
+
};
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Detect rapid-fire email sending patterns (multiple emails sent in quick succession)
|
|
362
|
+
* Returns suspicious patterns indicating potential spam.
|
|
363
|
+
*
|
|
364
|
+
* NOTE: Analysis is limited to the most recent MAX_SPAM_DETECTION_LIMIT operations
|
|
365
|
+
* in the time window to avoid query read limit errors.
|
|
366
|
+
*/
|
|
367
|
+
export const detectRapidFirePatterns = query({
|
|
368
|
+
args: {
|
|
369
|
+
timeWindowMs: v.optional(v.number()),
|
|
370
|
+
minEmailsInWindow: v.optional(v.number()),
|
|
371
|
+
},
|
|
372
|
+
returns: v.array(rapidFirePatternValidator),
|
|
373
|
+
handler: async (ctx, args) => {
|
|
374
|
+
const timeWindowMs = args.timeWindowMs ?? 60000;
|
|
375
|
+
const minEmailsInWindow = args.minEmailsInWindow ?? 5;
|
|
376
|
+
const cutoffTime = Date.now() - timeWindowMs;
|
|
377
|
+
|
|
378
|
+
const operations = await ctx.db
|
|
379
|
+
.query("emailOperations")
|
|
380
|
+
.withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
|
|
381
|
+
.take(MAX_SPAM_DETECTION_LIMIT);
|
|
382
|
+
|
|
383
|
+
const sortedOps = [...operations].sort((a, b) => a.timestamp - b.timestamp);
|
|
384
|
+
|
|
385
|
+
const patterns: Array<{
|
|
386
|
+
email?: string;
|
|
387
|
+
actorId?: string;
|
|
388
|
+
count: number;
|
|
389
|
+
timeWindowMs: number;
|
|
390
|
+
firstTimestamp: number;
|
|
391
|
+
lastTimestamp: number;
|
|
392
|
+
}> = [];
|
|
393
|
+
|
|
394
|
+
const emailGroups = new Map<string, typeof sortedOps>();
|
|
395
|
+
for (const op of sortedOps) {
|
|
396
|
+
if (op.email && op.email !== "audience") {
|
|
397
|
+
if (!emailGroups.has(op.email)) {
|
|
398
|
+
emailGroups.set(op.email, []);
|
|
399
|
+
}
|
|
400
|
+
emailGroups.get(op.email)?.push(op);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
for (const [email, ops] of emailGroups.entries()) {
|
|
405
|
+
for (let i = 0; i < ops.length; i++) {
|
|
406
|
+
const op = ops[i];
|
|
407
|
+
if (!op) continue;
|
|
408
|
+
|
|
409
|
+
const windowStart = op.timestamp;
|
|
410
|
+
const windowEnd = windowStart + timeWindowMs;
|
|
411
|
+
const opsInWindow = ops.filter(
|
|
412
|
+
(op) => op.timestamp >= windowStart && op.timestamp <= windowEnd,
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
if (opsInWindow.length >= minEmailsInWindow) {
|
|
416
|
+
patterns.push({
|
|
417
|
+
email,
|
|
418
|
+
count: opsInWindow.length,
|
|
419
|
+
timeWindowMs,
|
|
420
|
+
firstTimestamp: windowStart,
|
|
421
|
+
lastTimestamp: windowEnd,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const actorGroups = new Map<string, typeof sortedOps>();
|
|
428
|
+
for (const op of sortedOps) {
|
|
429
|
+
if (op.actorId) {
|
|
430
|
+
if (!actorGroups.has(op.actorId)) {
|
|
431
|
+
actorGroups.set(op.actorId, []);
|
|
432
|
+
}
|
|
433
|
+
actorGroups.get(op.actorId)?.push(op);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
for (const [actorId, ops] of actorGroups.entries()) {
|
|
438
|
+
for (let i = 0; i < ops.length; i++) {
|
|
439
|
+
const op = ops[i];
|
|
440
|
+
if (!op) continue;
|
|
441
|
+
|
|
442
|
+
const windowStart = op.timestamp;
|
|
443
|
+
const windowEnd = windowStart + timeWindowMs;
|
|
444
|
+
const opsInWindow = ops.filter(
|
|
445
|
+
(op) => op.timestamp >= windowStart && op.timestamp <= windowEnd,
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
if (opsInWindow.length >= minEmailsInWindow) {
|
|
449
|
+
patterns.push({
|
|
450
|
+
actorId,
|
|
451
|
+
count: opsInWindow.length,
|
|
452
|
+
timeWindowMs,
|
|
453
|
+
firstTimestamp: windowStart,
|
|
454
|
+
lastTimestamp: windowEnd,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return patterns;
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Rate limiting: Check if an email can be sent to a recipient
|
|
466
|
+
* Based on recent email operations in the database.
|
|
467
|
+
*
|
|
468
|
+
* Uses efficient .take() query - only reads the minimum number of documents
|
|
469
|
+
* needed to determine if the rate limit is exceeded.
|
|
470
|
+
*/
|
|
471
|
+
export const checkRecipientRateLimit = query({
|
|
472
|
+
args: {
|
|
473
|
+
email: v.string(),
|
|
474
|
+
timeWindowMs: v.number(),
|
|
475
|
+
maxEmails: v.number(),
|
|
476
|
+
},
|
|
477
|
+
returns: rateLimitResponseValidator,
|
|
478
|
+
handler: async (ctx, args) => {
|
|
479
|
+
const cutoffTime = Date.now() - args.timeWindowMs;
|
|
480
|
+
|
|
481
|
+
// Use the compound index (email, timestamp) to efficiently query
|
|
482
|
+
// Only fetch up to maxEmails + 1 to check if limit exceeded
|
|
483
|
+
const operations = await ctx.db
|
|
484
|
+
.query("emailOperations")
|
|
485
|
+
.withIndex("email", (q) =>
|
|
486
|
+
q.eq("email", args.email).gte("timestamp", cutoffTime),
|
|
487
|
+
)
|
|
488
|
+
.take(args.maxEmails + 1);
|
|
489
|
+
|
|
490
|
+
// Filter for successful operations only
|
|
491
|
+
const recentOps = operations.filter((op) => op.success);
|
|
492
|
+
const count = recentOps.length;
|
|
493
|
+
const allowed = count < args.maxEmails;
|
|
494
|
+
|
|
495
|
+
let retryAfter: number | undefined;
|
|
496
|
+
if (!allowed && recentOps.length > 0) {
|
|
497
|
+
const oldestOp = recentOps.reduce((oldest, op) =>
|
|
498
|
+
op.timestamp < oldest.timestamp ? op : oldest,
|
|
499
|
+
);
|
|
500
|
+
retryAfter = oldestOp.timestamp + args.timeWindowMs - Date.now();
|
|
501
|
+
if (retryAfter < 0) retryAfter = 0;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
allowed,
|
|
506
|
+
count,
|
|
507
|
+
limit: args.maxEmails,
|
|
508
|
+
timeWindowMs: args.timeWindowMs,
|
|
509
|
+
retryAfter,
|
|
510
|
+
};
|
|
511
|
+
},
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Rate limiting: Check if an actor/user can send more emails
|
|
516
|
+
* Based on recent email operations in the database.
|
|
517
|
+
*
|
|
518
|
+
* Uses efficient .take() query - only reads the minimum number of documents
|
|
519
|
+
* needed to determine if the rate limit is exceeded.
|
|
520
|
+
*/
|
|
521
|
+
export const checkActorRateLimit = query({
|
|
522
|
+
args: {
|
|
523
|
+
actorId: v.string(),
|
|
524
|
+
timeWindowMs: v.number(),
|
|
525
|
+
maxEmails: v.number(),
|
|
526
|
+
},
|
|
527
|
+
returns: rateLimitResponseValidator,
|
|
528
|
+
handler: async (ctx, args) => {
|
|
529
|
+
const cutoffTime = Date.now() - args.timeWindowMs;
|
|
530
|
+
|
|
531
|
+
// Use the compound index (actorId, timestamp) to efficiently query
|
|
532
|
+
// Only fetch up to maxEmails + 1 to check if limit exceeded
|
|
533
|
+
const operations = await ctx.db
|
|
534
|
+
.query("emailOperations")
|
|
535
|
+
.withIndex("actorId", (q) =>
|
|
536
|
+
q.eq("actorId", args.actorId).gte("timestamp", cutoffTime),
|
|
537
|
+
)
|
|
538
|
+
.take(args.maxEmails + 1);
|
|
539
|
+
|
|
540
|
+
// Filter for successful operations only
|
|
541
|
+
const recentOps = operations.filter((op) => op.success);
|
|
542
|
+
const count = recentOps.length;
|
|
543
|
+
const allowed = count < args.maxEmails;
|
|
544
|
+
|
|
545
|
+
let retryAfter: number | undefined;
|
|
546
|
+
if (!allowed && recentOps.length > 0) {
|
|
547
|
+
const oldestOp = recentOps.reduce((oldest, op) =>
|
|
548
|
+
op.timestamp < oldest.timestamp ? op : oldest,
|
|
549
|
+
);
|
|
550
|
+
retryAfter = oldestOp.timestamp + args.timeWindowMs - Date.now();
|
|
551
|
+
if (retryAfter < 0) retryAfter = 0;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return {
|
|
555
|
+
allowed,
|
|
556
|
+
count,
|
|
557
|
+
limit: args.maxEmails,
|
|
558
|
+
timeWindowMs: args.timeWindowMs,
|
|
559
|
+
retryAfter,
|
|
560
|
+
};
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Rate limiting: Check global email sending rate
|
|
566
|
+
* Checks total email operations across all senders.
|
|
567
|
+
*
|
|
568
|
+
* Uses efficient .take() query - only reads the minimum number of documents
|
|
569
|
+
* needed to determine if the rate limit is exceeded.
|
|
570
|
+
*/
|
|
571
|
+
export const checkGlobalRateLimit = query({
|
|
572
|
+
args: {
|
|
573
|
+
timeWindowMs: v.number(),
|
|
574
|
+
maxEmails: v.number(),
|
|
575
|
+
},
|
|
576
|
+
returns: v.object({
|
|
577
|
+
allowed: v.boolean(),
|
|
578
|
+
count: v.number(),
|
|
579
|
+
limit: v.number(),
|
|
580
|
+
timeWindowMs: v.number(),
|
|
581
|
+
}),
|
|
582
|
+
handler: async (ctx, args) => {
|
|
583
|
+
const cutoffTime = Date.now() - args.timeWindowMs;
|
|
584
|
+
|
|
585
|
+
// Use the timestamp index to efficiently query recent operations
|
|
586
|
+
// Only fetch up to maxEmails + 1 to check if limit exceeded
|
|
587
|
+
const operations = await ctx.db
|
|
588
|
+
.query("emailOperations")
|
|
589
|
+
.withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
|
|
590
|
+
.take(args.maxEmails + 1);
|
|
591
|
+
|
|
592
|
+
// Filter for successful operations only
|
|
593
|
+
const recentOps = operations.filter((op) => op.success);
|
|
594
|
+
const count = recentOps.length;
|
|
595
|
+
const allowed = count < args.maxEmails;
|
|
596
|
+
|
|
597
|
+
return {
|
|
598
|
+
allowed,
|
|
599
|
+
count,
|
|
600
|
+
limit: args.maxEmails,
|
|
601
|
+
timeWindowMs: args.timeWindowMs,
|
|
602
|
+
};
|
|
603
|
+
},
|
|
604
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineSchema, defineTable } from "convex/server";
|
|
2
|
+
import { contactsFields } from "./tables/contacts";
|
|
3
|
+
import { emailOperationsFields } from "./tables/emailOperations";
|
|
4
|
+
|
|
5
|
+
export default defineSchema({
|
|
6
|
+
contacts: defineTable(contactsFields)
|
|
7
|
+
.index("email", ["email"])
|
|
8
|
+
.index("userId", ["userId"])
|
|
9
|
+
.index("userGroup", ["userGroup"])
|
|
10
|
+
.index("source", ["source"])
|
|
11
|
+
.index("subscribed", ["subscribed"]),
|
|
12
|
+
emailOperations: defineTable(emailOperationsFields)
|
|
13
|
+
.index("email", ["email", "timestamp"])
|
|
14
|
+
.index("actorId", ["actorId", "timestamp"])
|
|
15
|
+
.index("operationType", ["operationType", "timestamp"])
|
|
16
|
+
.index("timestamp", ["timestamp"]),
|
|
17
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Contacts table field definitions
|
|
5
|
+
*/
|
|
6
|
+
export const contactsFields = {
|
|
7
|
+
email: v.string(),
|
|
8
|
+
firstName: v.optional(v.string()),
|
|
9
|
+
lastName: v.optional(v.string()),
|
|
10
|
+
userId: v.optional(v.string()),
|
|
11
|
+
source: v.optional(v.string()),
|
|
12
|
+
subscribed: v.optional(v.boolean()),
|
|
13
|
+
userGroup: v.optional(v.string()),
|
|
14
|
+
loopsContactId: v.optional(v.string()),
|
|
15
|
+
createdAt: v.number(),
|
|
16
|
+
updatedAt: v.number(),
|
|
17
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Email operations table field definitions
|
|
5
|
+
*/
|
|
6
|
+
export const emailOperationsFields = {
|
|
7
|
+
operationType: v.union(
|
|
8
|
+
v.literal("transactional"),
|
|
9
|
+
v.literal("event"),
|
|
10
|
+
v.literal("campaign"),
|
|
11
|
+
v.literal("loop"),
|
|
12
|
+
),
|
|
13
|
+
email: v.string(),
|
|
14
|
+
actorId: v.optional(v.string()),
|
|
15
|
+
transactionalId: v.optional(v.string()),
|
|
16
|
+
campaignId: v.optional(v.string()),
|
|
17
|
+
loopId: v.optional(v.string()),
|
|
18
|
+
eventName: v.optional(v.string()),
|
|
19
|
+
timestamp: v.number(),
|
|
20
|
+
success: v.boolean(),
|
|
21
|
+
messageId: v.optional(v.string()),
|
|
22
|
+
metadata: v.optional(v.record(v.string(), v.any())),
|
|
23
|
+
};
|