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