@devwithbobby/loops 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.
Files changed (59) hide show
  1. package/.changeset/README.md +8 -0
  2. package/.changeset/config.json +14 -0
  3. package/.config/commitlint.config.ts +11 -0
  4. package/.config/lefthook.yml +11 -0
  5. package/.github/workflows/release.yml +52 -0
  6. package/.github/workflows/test-and-lint.yml +39 -0
  7. package/README.md +517 -0
  8. package/biome.json +45 -0
  9. package/bun.lock +1166 -0
  10. package/bunfig.toml +7 -0
  11. package/convex.json +3 -0
  12. package/example/CLAUDE.md +106 -0
  13. package/example/README.md +21 -0
  14. package/example/bun-env.d.ts +17 -0
  15. package/example/convex/_generated/api.d.ts +53 -0
  16. package/example/convex/_generated/api.js +23 -0
  17. package/example/convex/_generated/dataModel.d.ts +60 -0
  18. package/example/convex/_generated/server.d.ts +149 -0
  19. package/example/convex/_generated/server.js +90 -0
  20. package/example/convex/convex.config.ts +7 -0
  21. package/example/convex/example.ts +76 -0
  22. package/example/convex/schema.ts +3 -0
  23. package/example/convex/tsconfig.json +34 -0
  24. package/example/src/App.tsx +185 -0
  25. package/example/src/frontend.tsx +39 -0
  26. package/example/src/index.css +15 -0
  27. package/example/src/index.html +12 -0
  28. package/example/src/index.tsx +19 -0
  29. package/example/tsconfig.json +28 -0
  30. package/package.json +95 -0
  31. package/prds/CHANGELOG.md +38 -0
  32. package/prds/CLAUDE.md +408 -0
  33. package/prds/CONTRIBUTING.md +274 -0
  34. package/prds/ENV_SETUP.md +222 -0
  35. package/prds/MONITORING.md +301 -0
  36. package/prds/RATE_LIMITING.md +412 -0
  37. package/prds/SECURITY.md +246 -0
  38. package/renovate.json +32 -0
  39. package/src/client/index.ts +530 -0
  40. package/src/client/types.ts +64 -0
  41. package/src/component/_generated/api.d.ts +55 -0
  42. package/src/component/_generated/api.js +23 -0
  43. package/src/component/_generated/dataModel.d.ts +60 -0
  44. package/src/component/_generated/server.d.ts +149 -0
  45. package/src/component/_generated/server.js +90 -0
  46. package/src/component/convex.config.ts +27 -0
  47. package/src/component/lib.ts +1125 -0
  48. package/src/component/schema.ts +17 -0
  49. package/src/component/tables/contacts.ts +16 -0
  50. package/src/component/tables/emailOperations.ts +22 -0
  51. package/src/component/validators.ts +39 -0
  52. package/src/utils.ts +6 -0
  53. package/test/client/_generated/_ignore.ts +1 -0
  54. package/test/client/index.test.ts +65 -0
  55. package/test/client/setup.test.ts +54 -0
  56. package/test/component/lib.test.ts +225 -0
  57. package/test/component/setup.test.ts +21 -0
  58. package/tsconfig.build.json +20 -0
  59. package/tsconfig.json +22 -0
@@ -0,0 +1,1125 @@
1
+ import { z } from "zod";
2
+ import { za, zm, zq } from "../utils.js";
3
+ import { internal } from "./_generated/api";
4
+ import { contactValidator } from "./validators.js";
5
+
6
+ const LOOPS_API_BASE_URL = "https://app.loops.so/api/v1";
7
+
8
+ /**
9
+ * Sanitize error messages to avoid leaking sensitive information
10
+ */
11
+ const sanitizeError = (status: number, errorText: string): Error => {
12
+ if (status === 401 || status === 403) {
13
+ return new Error("Authentication failed. Please check your API key.");
14
+ }
15
+ if (status === 404) {
16
+ return new Error("Resource not found.");
17
+ }
18
+ if (status === 429) {
19
+ return new Error("Rate limit exceeded. Please try again later.");
20
+ }
21
+ if (status >= 500) {
22
+ return new Error("Loops service error. Please try again later.");
23
+ }
24
+ return new Error(`Loops API error (${status}). Please try again.`);
25
+ };
26
+
27
+ /**
28
+ * Internal mutation to store/update a contact in the database
29
+ */
30
+ export const storeContact = zm({
31
+ args: z.object({
32
+ email: z.string().email(),
33
+ firstName: z.string().optional(),
34
+ lastName: z.string().optional(),
35
+ userId: z.string().optional(),
36
+ source: z.string().optional(),
37
+ subscribed: z.boolean().optional(),
38
+ userGroup: z.string().optional(),
39
+ loopsContactId: z.string().optional(),
40
+ }),
41
+ returns: z.void(),
42
+ handler: async (ctx, args) => {
43
+ const now = Date.now();
44
+ const existing = await ctx.db
45
+ .query("contacts")
46
+ .withIndex("email", (q) => q.eq("email", args.email))
47
+ .unique();
48
+
49
+ if (existing) {
50
+ await ctx.db.patch(existing._id, {
51
+ firstName: args.firstName,
52
+ lastName: args.lastName,
53
+ userId: args.userId,
54
+ source: args.source,
55
+ subscribed: args.subscribed ?? existing.subscribed,
56
+ userGroup: args.userGroup,
57
+ loopsContactId: args.loopsContactId,
58
+ updatedAt: now,
59
+ });
60
+ } else {
61
+ await ctx.db.insert("contacts", {
62
+ email: args.email,
63
+ firstName: args.firstName,
64
+ lastName: args.lastName,
65
+ userId: args.userId,
66
+ source: args.source,
67
+ subscribed: args.subscribed ?? true,
68
+ userGroup: args.userGroup,
69
+ loopsContactId: args.loopsContactId,
70
+ createdAt: now,
71
+ updatedAt: now,
72
+ });
73
+ }
74
+ },
75
+ });
76
+
77
+ /**
78
+ * Internal mutation to delete a contact from the database
79
+ */
80
+ export const removeContact = zm({
81
+ args: z.object({
82
+ email: z.string().email(),
83
+ }),
84
+ returns: z.void(),
85
+ handler: async (ctx, args) => {
86
+ const existing = await ctx.db
87
+ .query("contacts")
88
+ .withIndex("email", (q) => q.eq("email", args.email))
89
+ .unique();
90
+
91
+ if (existing) {
92
+ await ctx.db.delete(existing._id);
93
+ }
94
+ },
95
+ });
96
+
97
+ /**
98
+ * Internal mutation to log an email operation for monitoring
99
+ */
100
+ export const logEmailOperation = zm({
101
+ args: z.object({
102
+ operationType: z.enum(["transactional", "event", "campaign", "loop"]),
103
+ email: z.string().email(),
104
+ actorId: z.string().optional(),
105
+ transactionalId: z.string().optional(),
106
+ campaignId: z.string().optional(),
107
+ loopId: z.string().optional(),
108
+ eventName: z.string().optional(),
109
+ success: z.boolean(),
110
+ messageId: z.string().optional(),
111
+ metadata: z.record(z.string(), z.any()).optional(),
112
+ }),
113
+ returns: z.void(),
114
+ handler: async (ctx, args) => {
115
+ const operationData: Record<string, any> = {
116
+ operationType: args.operationType,
117
+ email: args.email,
118
+ timestamp: Date.now(),
119
+ success: args.success,
120
+ };
121
+
122
+ if (args.actorId) operationData.actorId = args.actorId;
123
+ if (args.transactionalId) operationData.transactionalId = args.transactionalId;
124
+ if (args.campaignId) operationData.campaignId = args.campaignId;
125
+ if (args.loopId) operationData.loopId = args.loopId;
126
+ if (args.eventName) operationData.eventName = args.eventName;
127
+ if (args.messageId) operationData.messageId = args.messageId;
128
+ if (args.metadata) operationData.metadata = args.metadata;
129
+
130
+ await ctx.db.insert("emailOperations", operationData as any);
131
+ },
132
+ });
133
+
134
+ /**
135
+ * Count contacts in the database
136
+ * Can filter by audience criteria (userGroup, source, subscribed status)
137
+ */
138
+ export const countContacts = zq({
139
+ args: z.object({
140
+ userGroup: z.string().optional(),
141
+ source: z.string().optional(),
142
+ subscribed: z.boolean().optional(),
143
+ }),
144
+ returns: z.number(),
145
+ handler: async (ctx, args) => {
146
+ let contacts;
147
+ if (args.userGroup !== undefined) {
148
+ contacts = await ctx.db
149
+ .query("contacts")
150
+ .withIndex("userGroup", (q) => q.eq("userGroup", args.userGroup))
151
+ .collect();
152
+ } else if (args.source !== undefined) {
153
+ contacts = await ctx.db
154
+ .query("contacts")
155
+ .withIndex("source", (q) => q.eq("source", args.source))
156
+ .collect();
157
+ } else if (args.subscribed !== undefined) {
158
+ contacts = await ctx.db
159
+ .query("contacts")
160
+ .withIndex("subscribed", (q) => q.eq("subscribed", args.subscribed))
161
+ .collect();
162
+ } else {
163
+ contacts = await ctx.db.query("contacts").collect();
164
+ }
165
+
166
+ if (args.userGroup !== undefined && contacts) {
167
+ contacts = contacts.filter((c) => c.userGroup === args.userGroup);
168
+ }
169
+ if (args.source !== undefined && contacts) {
170
+ contacts = contacts.filter((c) => c.source === args.source);
171
+ }
172
+ if (args.subscribed !== undefined && contacts) {
173
+ contacts = contacts.filter((c) => c.subscribed === args.subscribed);
174
+ }
175
+
176
+ return contacts.length;
177
+ },
178
+ });
179
+
180
+ /**
181
+ * Add or update a contact in Loops
182
+ */
183
+ export const addContact = za({
184
+ args: z.object({
185
+ apiKey: z.string(),
186
+ contact: contactValidator,
187
+ }),
188
+ returns: z.object({
189
+ success: z.boolean(),
190
+ id: z.string().optional(),
191
+ }),
192
+ handler: async (ctx, args) => {
193
+ const response = await fetch(`${LOOPS_API_BASE_URL}/contacts/create`, {
194
+ method: "POST",
195
+ headers: {
196
+ Authorization: `Bearer ${args.apiKey}`,
197
+ "Content-Type": "application/json",
198
+ },
199
+ body: JSON.stringify(args.contact),
200
+ });
201
+
202
+ if (!response.ok) {
203
+ const errorText = await response.text();
204
+ console.error(`Loops API error [${response.status}]:`, errorText);
205
+ throw sanitizeError(response.status, errorText);
206
+ }
207
+
208
+ const data = (await response.json()) as { id?: string };
209
+
210
+ await ctx.runMutation(((internal as any).lib).storeContact as any, {
211
+ email: args.contact.email,
212
+ firstName: args.contact.firstName,
213
+ lastName: args.contact.lastName,
214
+ userId: args.contact.userId,
215
+ source: args.contact.source,
216
+ subscribed: args.contact.subscribed,
217
+ userGroup: args.contact.userGroup,
218
+ loopsContactId: data.id,
219
+ });
220
+
221
+ return {
222
+ success: true,
223
+ id: data.id,
224
+ };
225
+ },
226
+ });
227
+
228
+ /**
229
+ * Update an existing contact in Loops
230
+ */
231
+ export const updateContact = za({
232
+ args: z.object({
233
+ apiKey: z.string(),
234
+ email: z.string().email(),
235
+ dataVariables: z.record(z.string(), z.any()).optional(),
236
+ firstName: z.string().optional(),
237
+ lastName: z.string().optional(),
238
+ userId: z.string().optional(),
239
+ source: z.string().optional(),
240
+ subscribed: z.boolean().optional(),
241
+ userGroup: z.string().optional(),
242
+ }),
243
+ returns: z.object({
244
+ success: z.boolean(),
245
+ }),
246
+ handler: async (ctx, args) => {
247
+ const response = await fetch(`${LOOPS_API_BASE_URL}/contacts/update`, {
248
+ method: "PUT",
249
+ headers: {
250
+ Authorization: `Bearer ${args.apiKey}`,
251
+ "Content-Type": "application/json",
252
+ },
253
+ body: JSON.stringify({
254
+ email: args.email,
255
+ dataVariables: args.dataVariables,
256
+ firstName: args.firstName,
257
+ lastName: args.lastName,
258
+ userId: args.userId,
259
+ source: args.source,
260
+ subscribed: args.subscribed,
261
+ userGroup: args.userGroup,
262
+ }),
263
+ });
264
+
265
+ if (!response.ok) {
266
+ const errorText = await response.text();
267
+ console.error(`Loops API error [${response.status}]:`, errorText);
268
+ throw sanitizeError(response.status, errorText);
269
+ }
270
+
271
+ await ctx.runMutation(((internal as any).lib).storeContact as any, {
272
+ email: args.email,
273
+ firstName: args.firstName,
274
+ lastName: args.lastName,
275
+ userId: args.userId,
276
+ source: args.source,
277
+ subscribed: args.subscribed,
278
+ userGroup: args.userGroup,
279
+ });
280
+
281
+ return { success: true };
282
+ },
283
+ });
284
+
285
+ /**
286
+ * Send a transactional email using a transactional ID
287
+ */
288
+ export const sendTransactional = za({
289
+ args: z.object({
290
+ apiKey: z.string(),
291
+ transactionalId: z.string(),
292
+ email: z.string().email(),
293
+ dataVariables: z.record(z.string(), z.any()).optional(),
294
+ }),
295
+ returns: z.object({
296
+ success: z.boolean(),
297
+ messageId: z.string().optional(),
298
+ }),
299
+ handler: async (ctx, args) => {
300
+ const response = await fetch(`${LOOPS_API_BASE_URL}/transactional`, {
301
+ method: "POST",
302
+ headers: {
303
+ Authorization: `Bearer ${args.apiKey}`,
304
+ "Content-Type": "application/json",
305
+ },
306
+ body: JSON.stringify({
307
+ transactionalId: args.transactionalId,
308
+ email: args.email,
309
+ dataVariables: args.dataVariables,
310
+ }),
311
+ });
312
+
313
+ if (!response.ok) {
314
+ const errorText = await response.text();
315
+ console.error(`Loops API error [${response.status}]:`, errorText);
316
+ await ctx.runMutation(((internal as any).lib).logEmailOperation as any, {
317
+ operationType: "transactional",
318
+ email: args.email,
319
+ success: false,
320
+ transactionalId: args.transactionalId,
321
+ });
322
+
323
+ throw sanitizeError(response.status, errorText);
324
+ }
325
+
326
+ const data = (await response.json()) as { messageId?: string };
327
+
328
+ await ctx.runMutation(((internal as any).lib).logEmailOperation as any, {
329
+ operationType: "transactional",
330
+ email: args.email,
331
+ success: true,
332
+ transactionalId: args.transactionalId,
333
+ messageId: data.messageId,
334
+ });
335
+
336
+ return {
337
+ success: true,
338
+ messageId: data.messageId,
339
+ };
340
+ },
341
+ });
342
+
343
+ /**
344
+ * Send an event to Loops to trigger email workflows
345
+ */
346
+ export const sendEvent = za({
347
+ args: z.object({
348
+ apiKey: z.string(),
349
+ email: z.string().email(),
350
+ eventName: z.string(),
351
+ eventProperties: z.record(z.string(), z.any()).optional(),
352
+ }),
353
+ returns: z.object({
354
+ success: z.boolean(),
355
+ }),
356
+ handler: async (ctx, args) => {
357
+ const response = await fetch(`${LOOPS_API_BASE_URL}/events/send`, {
358
+ method: "POST",
359
+ headers: {
360
+ Authorization: `Bearer ${args.apiKey}`,
361
+ "Content-Type": "application/json",
362
+ },
363
+ body: JSON.stringify({
364
+ email: args.email,
365
+ eventName: args.eventName,
366
+ eventProperties: args.eventProperties,
367
+ }),
368
+ });
369
+
370
+ if (!response.ok) {
371
+ const errorText = await response.text();
372
+ console.error(`Loops API error [${response.status}]:`, errorText);
373
+ throw sanitizeError(response.status, errorText);
374
+ }
375
+
376
+ return { success: true };
377
+ },
378
+ });
379
+
380
+ /**
381
+ * Delete a contact from Loops
382
+ */
383
+ export const deleteContact = za({
384
+ args: z.object({
385
+ apiKey: z.string(),
386
+ email: z.string().email(),
387
+ }),
388
+ returns: z.object({
389
+ success: z.boolean(),
390
+ }),
391
+ handler: async (ctx, args) => {
392
+ const response = await fetch(`${LOOPS_API_BASE_URL}/contacts/delete`, {
393
+ method: "POST",
394
+ headers: {
395
+ Authorization: `Bearer ${args.apiKey}`,
396
+ "Content-Type": "application/json",
397
+ },
398
+ body: JSON.stringify({ email: args.email }),
399
+ });
400
+
401
+ if (!response.ok) {
402
+ const errorText = await response.text();
403
+ console.error(`Loops API error [${response.status}]:`, errorText);
404
+ throw sanitizeError(response.status, errorText);
405
+ }
406
+
407
+ await ctx.runMutation(((internal as any).lib).removeContact as any, {
408
+ email: args.email,
409
+ });
410
+
411
+ return { success: true };
412
+ },
413
+ });
414
+
415
+ /**
416
+ * Send a campaign to contacts
417
+ * Campaigns are one-time email sends to a segment or list of contacts
418
+ */
419
+ export const sendCampaign = za({
420
+ args: z.object({
421
+ apiKey: z.string(),
422
+ campaignId: z.string(),
423
+ emails: z.array(z.string().email()).optional(),
424
+ transactionalId: z.string().optional(),
425
+ dataVariables: z.record(z.string(), z.any()).optional(),
426
+ audienceFilters: z
427
+ .object({
428
+ userGroup: z.string().optional(),
429
+ source: z.string().optional(),
430
+ })
431
+ .optional(),
432
+ }),
433
+ returns: z.object({
434
+ success: z.boolean(),
435
+ messageId: z.string().optional(),
436
+ }),
437
+ handler: async (ctx, args) => {
438
+ const payload: Record<string, any> = {
439
+ campaignId: args.campaignId,
440
+ };
441
+
442
+ if (args.emails && args.emails.length > 0) {
443
+ payload.emails = args.emails;
444
+ }
445
+
446
+ if (args.transactionalId) {
447
+ payload.transactionalId = args.transactionalId;
448
+ }
449
+
450
+ if (args.dataVariables) {
451
+ payload.dataVariables = args.dataVariables;
452
+ }
453
+
454
+ if (args.audienceFilters) {
455
+ payload.audienceFilters = args.audienceFilters;
456
+ }
457
+
458
+ const response = await fetch(`${LOOPS_API_BASE_URL}/campaigns/send`, {
459
+ method: "POST",
460
+ headers: {
461
+ Authorization: `Bearer ${args.apiKey}`,
462
+ "Content-Type": "application/json",
463
+ },
464
+ body: JSON.stringify(payload),
465
+ });
466
+
467
+ if (!response.ok) {
468
+ const errorText = await response.text();
469
+ console.error(`Loops API error [${response.status}]:`, errorText);
470
+ throw sanitizeError(response.status, errorText);
471
+ }
472
+
473
+ const data = (await response.json()) as { messageId?: string };
474
+
475
+ if (args.emails && args.emails.length > 0) {
476
+ for (const email of args.emails) {
477
+ await ctx.runMutation(((internal as any).lib).logEmailOperation as any, {
478
+ operationType: "campaign",
479
+ email,
480
+ success: true,
481
+ campaignId: args.campaignId,
482
+ messageId: data.messageId,
483
+ });
484
+ }
485
+ } else {
486
+ await ctx.runMutation(((internal as any).lib).logEmailOperation as any, {
487
+ operationType: "campaign",
488
+ email: "audience",
489
+ success: true,
490
+ campaignId: args.campaignId,
491
+ messageId: data.messageId,
492
+ metadata: { audienceFilters: args.audienceFilters },
493
+ });
494
+ }
495
+
496
+ return {
497
+ success: true,
498
+ messageId: data.messageId,
499
+ };
500
+ },
501
+ });
502
+
503
+ /**
504
+ * Trigger a loop for a contact
505
+ * Loops are automated email sequences that can be triggered by events
506
+ * This is similar to sendEvent but specifically for loops
507
+ */
508
+ export const triggerLoop = za({
509
+ args: z.object({
510
+ apiKey: z.string(),
511
+ loopId: z.string(),
512
+ email: z.string().email(),
513
+ dataVariables: z.record(z.string(), z.any()).optional(),
514
+ }),
515
+ returns: z.object({
516
+ success: z.boolean(),
517
+ }),
518
+ handler: async (ctx, args) => {
519
+ const response = await fetch(`${LOOPS_API_BASE_URL}/loops/trigger`, {
520
+ method: "POST",
521
+ headers: {
522
+ Authorization: `Bearer ${args.apiKey}`,
523
+ "Content-Type": "application/json",
524
+ },
525
+ body: JSON.stringify({
526
+ loopId: args.loopId,
527
+ email: args.email,
528
+ dataVariables: args.dataVariables,
529
+ }),
530
+ });
531
+
532
+ if (!response.ok) {
533
+ const errorText = await response.text();
534
+ console.error(`Loops API error [${response.status}]:`, errorText);
535
+
536
+ await ctx.runMutation(((internal as any).lib).logEmailOperation as any, {
537
+ operationType: "loop",
538
+ email: args.email,
539
+ success: false,
540
+ loopId: args.loopId,
541
+ });
542
+
543
+ throw sanitizeError(response.status, errorText);
544
+ }
545
+
546
+ await ctx.runMutation(((internal as any).lib).logEmailOperation as any, {
547
+ operationType: "loop",
548
+ email: args.email,
549
+ success: true,
550
+ loopId: args.loopId,
551
+ });
552
+
553
+ return { success: true };
554
+ },
555
+ });
556
+
557
+ /**
558
+ * Find a contact by email
559
+ * Retrieves contact information from Loops
560
+ */
561
+ export const findContact = za({
562
+ args: z.object({
563
+ apiKey: z.string(),
564
+ email: z.string().email(),
565
+ }),
566
+ returns: z.object({
567
+ success: z.boolean(),
568
+ contact: z
569
+ .object({
570
+ id: z.string().optional(),
571
+ email: z.string().optional(),
572
+ firstName: z.string().optional(),
573
+ lastName: z.string().optional(),
574
+ source: z.string().optional(),
575
+ subscribed: z.boolean().optional(),
576
+ userGroup: z.string().optional(),
577
+ userId: z.string().optional(),
578
+ createdAt: z.string().optional(),
579
+ })
580
+ .optional(),
581
+ }),
582
+ handler: async (ctx, args) => {
583
+ const response = await fetch(
584
+ `${LOOPS_API_BASE_URL}/contacts/find?email=${encodeURIComponent(args.email)}`,
585
+ {
586
+ method: "GET",
587
+ headers: {
588
+ Authorization: `Bearer ${args.apiKey}`,
589
+ "Content-Type": "application/json",
590
+ },
591
+ },
592
+ );
593
+
594
+ if (!response.ok) {
595
+ if (response.status === 404) {
596
+ return { success: false, contact: undefined };
597
+ }
598
+ const errorText = await response.text();
599
+ console.error(`Loops API error [${response.status}]:`, errorText);
600
+ throw sanitizeError(response.status, errorText);
601
+ }
602
+
603
+ const data = (await response.json()) as Record<string, any>;
604
+
605
+ return {
606
+ success: true,
607
+ contact: data,
608
+ };
609
+ },
610
+ });
611
+
612
+ /**
613
+ * Batch create contacts
614
+ * Create multiple contacts in a single API call
615
+ */
616
+ export const batchCreateContacts = za({
617
+ args: z.object({
618
+ apiKey: z.string(),
619
+ contacts: z.array(contactValidator),
620
+ }),
621
+ returns: z.object({
622
+ success: z.boolean(),
623
+ created: z.number().optional(),
624
+ }),
625
+ handler: async (ctx, args) => {
626
+ const response = await fetch(`${LOOPS_API_BASE_URL}/contacts/batch`, {
627
+ method: "POST",
628
+ headers: {
629
+ Authorization: `Bearer ${args.apiKey}`,
630
+ "Content-Type": "application/json",
631
+ },
632
+ body: JSON.stringify({ contacts: args.contacts }),
633
+ });
634
+
635
+ if (!response.ok) {
636
+ const errorText = await response.text();
637
+ console.error(`Loops API error [${response.status}]:`, errorText);
638
+ throw sanitizeError(response.status, errorText);
639
+ }
640
+
641
+ const data = (await response.json()) as { created?: number };
642
+
643
+ for (const contact of args.contacts) {
644
+ await ctx.runMutation(((internal as any).lib.storeContact) as any, {
645
+ email: contact.email,
646
+ firstName: contact.firstName,
647
+ lastName: contact.lastName,
648
+ userId: contact.userId,
649
+ source: contact.source,
650
+ subscribed: contact.subscribed,
651
+ userGroup: contact.userGroup,
652
+ });
653
+ }
654
+
655
+ return {
656
+ success: true,
657
+ created: data.created ?? args.contacts.length,
658
+ };
659
+ },
660
+ });
661
+
662
+ /**
663
+ * Unsubscribe a contact
664
+ * Unsubscribes a contact from receiving emails (they remain in the system)
665
+ */
666
+ export const unsubscribeContact = za({
667
+ args: z.object({
668
+ apiKey: z.string(),
669
+ email: z.string().email(),
670
+ }),
671
+ returns: z.object({
672
+ success: z.boolean(),
673
+ }),
674
+ handler: async (ctx, args) => {
675
+ const response = await fetch(`${LOOPS_API_BASE_URL}/contacts/unsubscribe`, {
676
+ method: "POST",
677
+ headers: {
678
+ Authorization: `Bearer ${args.apiKey}`,
679
+ "Content-Type": "application/json",
680
+ },
681
+ body: JSON.stringify({ email: args.email }),
682
+ });
683
+
684
+ if (!response.ok) {
685
+ const errorText = await response.text();
686
+ console.error(`Loops API error [${response.status}]:`, errorText);
687
+ throw sanitizeError(response.status, errorText);
688
+ }
689
+
690
+ await ctx.runMutation(((internal as any).lib).storeContact as any, {
691
+ email: args.email,
692
+ subscribed: false,
693
+ });
694
+
695
+ return { success: true };
696
+ },
697
+ });
698
+
699
+ /**
700
+ * Resubscribe a contact
701
+ * Resubscribes a previously unsubscribed contact
702
+ */
703
+ export const resubscribeContact = za({
704
+ args: z.object({
705
+ apiKey: z.string(),
706
+ email: z.string().email(),
707
+ }),
708
+ returns: z.object({
709
+ success: z.boolean(),
710
+ }),
711
+ handler: async (ctx, args) => {
712
+ const response = await fetch(`${LOOPS_API_BASE_URL}/contacts/resubscribe`, {
713
+ method: "POST",
714
+ headers: {
715
+ Authorization: `Bearer ${args.apiKey}`,
716
+ "Content-Type": "application/json",
717
+ },
718
+ body: JSON.stringify({ email: args.email }),
719
+ });
720
+
721
+ if (!response.ok) {
722
+ const errorText = await response.text();
723
+ console.error(`Loops API error [${response.status}]:`, errorText);
724
+ throw sanitizeError(response.status, errorText);
725
+ }
726
+
727
+ await ctx.runMutation(((internal as any).lib).storeContact as any, {
728
+ email: args.email,
729
+ subscribed: true,
730
+ });
731
+
732
+ return { success: true };
733
+ },
734
+ });
735
+
736
+ /**
737
+ * Check for spam patterns: too many emails to the same recipient in a time window
738
+ * Returns email addresses that received too many emails
739
+ */
740
+ export const detectRecipientSpam = zq({
741
+ args: z.object({
742
+ timeWindowMs: z.number().default(3600000),
743
+ maxEmailsPerRecipient: z.number().default(10),
744
+ }),
745
+ returns: z.array(
746
+ z.object({
747
+ email: z.string(),
748
+ count: z.number(),
749
+ timeWindowMs: z.number(),
750
+ }),
751
+ ),
752
+ handler: async (ctx, args) => {
753
+ const cutoffTime = Date.now() - args.timeWindowMs;
754
+
755
+ const operations = await ctx.db
756
+ .query("emailOperations")
757
+ .withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
758
+ .collect();
759
+
760
+ const emailCounts = new Map<string, number>();
761
+ for (const op of operations) {
762
+ if (op.email && op.email !== "audience") {
763
+ emailCounts.set(op.email, (emailCounts.get(op.email) ?? 0) + 1);
764
+ }
765
+ }
766
+
767
+ const suspicious: Array<{ email: string; count: number; timeWindowMs: number }> = [];
768
+ for (const [email, count] of emailCounts.entries()) {
769
+ if (count > args.maxEmailsPerRecipient) {
770
+ suspicious.push({
771
+ email,
772
+ count,
773
+ timeWindowMs: args.timeWindowMs,
774
+ });
775
+ }
776
+ }
777
+
778
+ return suspicious;
779
+ },
780
+ });
781
+
782
+ /**
783
+ * Check for spam patterns: too many emails from the same actor/user
784
+ * Returns actor IDs that sent too many emails
785
+ */
786
+ export const detectActorSpam = zq({
787
+ args: z.object({
788
+ timeWindowMs: z.number().default(3600000),
789
+ maxEmailsPerActor: z.number().default(100),
790
+ }),
791
+ returns: z.array(
792
+ z.object({
793
+ actorId: z.string(),
794
+ count: z.number(),
795
+ timeWindowMs: z.number(),
796
+ }),
797
+ ),
798
+ handler: async (ctx, args) => {
799
+ const cutoffTime = Date.now() - args.timeWindowMs;
800
+
801
+ const operations = await ctx.db
802
+ .query("emailOperations")
803
+ .withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
804
+ .collect();
805
+
806
+ const actorCounts = new Map<string, number>();
807
+ for (const op of operations) {
808
+ if (op.actorId) {
809
+ actorCounts.set(op.actorId, (actorCounts.get(op.actorId) ?? 0) + 1);
810
+ }
811
+ }
812
+
813
+ const suspicious: Array<{ actorId: string; count: number; timeWindowMs: number }> = [];
814
+ for (const [actorId, count] of actorCounts.entries()) {
815
+ if (count > args.maxEmailsPerActor) {
816
+ suspicious.push({
817
+ actorId,
818
+ count,
819
+ timeWindowMs: args.timeWindowMs,
820
+ });
821
+ }
822
+ }
823
+
824
+ return suspicious;
825
+ },
826
+ });
827
+
828
+ /**
829
+ * Get recent email operation statistics for monitoring
830
+ */
831
+ export const getEmailStats = zq({
832
+ args: z.object({
833
+ timeWindowMs: z.number().default(86400000),
834
+ }),
835
+ returns: z.object({
836
+ totalOperations: z.number(),
837
+ successfulOperations: z.number(),
838
+ failedOperations: z.number(),
839
+ operationsByType: z.record(z.string(), z.number()),
840
+ uniqueRecipients: z.number(),
841
+ uniqueActors: z.number(),
842
+ }),
843
+ handler: async (ctx, args) => {
844
+ const cutoffTime = Date.now() - args.timeWindowMs;
845
+
846
+ const operations = await ctx.db
847
+ .query("emailOperations")
848
+ .withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
849
+ .collect();
850
+
851
+ const stats = {
852
+ totalOperations: operations.length,
853
+ successfulOperations: operations.filter((op) => op.success).length,
854
+ failedOperations: operations.filter((op) => !op.success).length,
855
+ operationsByType: {} as Record<string, number>,
856
+ uniqueRecipients: new Set<string>(),
857
+ uniqueActors: new Set<string>(),
858
+ };
859
+
860
+ for (const op of operations) {
861
+ stats.operationsByType[op.operationType] =
862
+ (stats.operationsByType[op.operationType] ?? 0) + 1;
863
+
864
+ if (op.email && op.email !== "audience") {
865
+ stats.uniqueRecipients.add(op.email);
866
+ }
867
+
868
+ if (op.actorId) {
869
+ stats.uniqueActors.add(op.actorId);
870
+ }
871
+ }
872
+
873
+ return {
874
+ ...stats,
875
+ uniqueRecipients: stats.uniqueRecipients.size,
876
+ uniqueActors: stats.uniqueActors.size,
877
+ };
878
+ },
879
+ });
880
+
881
+ /**
882
+ * Detect rapid-fire email sending patterns (multiple emails sent in quick succession)
883
+ * Returns suspicious patterns indicating potential spam
884
+ */
885
+ export const detectRapidFirePatterns = zq({
886
+ args: z.object({
887
+ timeWindowMs: z.number().default(60000),
888
+ minEmailsInWindow: z.number().default(5),
889
+ }),
890
+ returns: z.array(
891
+ z.object({
892
+ email: z.string().optional(),
893
+ actorId: z.string().optional(),
894
+ count: z.number(),
895
+ timeWindowMs: z.number(),
896
+ firstTimestamp: z.number(),
897
+ lastTimestamp: z.number(),
898
+ }),
899
+ ),
900
+ handler: async (ctx, args) => {
901
+ const cutoffTime = Date.now() - args.timeWindowMs;
902
+
903
+ const operations = await ctx.db
904
+ .query("emailOperations")
905
+ .withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
906
+ .collect();
907
+
908
+ operations.sort((a, b) => a.timestamp - b.timestamp);
909
+
910
+ const patterns: Array<{
911
+ email?: string;
912
+ actorId?: string;
913
+ count: number;
914
+ timeWindowMs: number;
915
+ firstTimestamp: number;
916
+ lastTimestamp: number;
917
+ }> = [];
918
+
919
+ const emailGroups = new Map<string, typeof operations>();
920
+ for (const op of operations) {
921
+ if (op.email && op.email !== "audience") {
922
+ if (!emailGroups.has(op.email)) {
923
+ emailGroups.set(op.email, []);
924
+ }
925
+ emailGroups.get(op.email)!.push(op);
926
+ }
927
+ }
928
+
929
+ for (const [email, ops] of emailGroups.entries()) {
930
+ for (let i = 0; i < ops.length; i++) {
931
+ const op = ops[i];
932
+ if (!op) continue;
933
+
934
+ const windowStart = op.timestamp;
935
+ const windowEnd = windowStart + args.timeWindowMs;
936
+ const opsInWindow = ops.filter(
937
+ (op) => op.timestamp >= windowStart && op.timestamp <= windowEnd,
938
+ );
939
+
940
+ if (opsInWindow.length >= args.minEmailsInWindow) {
941
+ patterns.push({
942
+ email,
943
+ count: opsInWindow.length,
944
+ timeWindowMs: args.timeWindowMs,
945
+ firstTimestamp: windowStart,
946
+ lastTimestamp: windowEnd,
947
+ });
948
+ }
949
+ }
950
+ }
951
+
952
+ const actorGroups = new Map<string, typeof operations>();
953
+ for (const op of operations) {
954
+ if (op.actorId) {
955
+ if (!actorGroups.has(op.actorId)) {
956
+ actorGroups.set(op.actorId, []);
957
+ }
958
+ actorGroups.get(op.actorId)!.push(op);
959
+ }
960
+ }
961
+
962
+ for (const [actorId, ops] of actorGroups.entries()) {
963
+ for (let i = 0; i < ops.length; i++) {
964
+ const op = ops[i];
965
+ if (!op) continue;
966
+
967
+ const windowStart = op.timestamp;
968
+ const windowEnd = windowStart + args.timeWindowMs;
969
+ const opsInWindow = ops.filter(
970
+ (op) => op.timestamp >= windowStart && op.timestamp <= windowEnd,
971
+ );
972
+
973
+ if (opsInWindow.length >= args.minEmailsInWindow) {
974
+ patterns.push({
975
+ actorId,
976
+ count: opsInWindow.length,
977
+ timeWindowMs: args.timeWindowMs,
978
+ firstTimestamp: windowStart,
979
+ lastTimestamp: windowEnd,
980
+ });
981
+ }
982
+ }
983
+ }
984
+
985
+ return patterns;
986
+ },
987
+ });
988
+
989
+ /**
990
+ * Rate limiting: Check if an email can be sent to a recipient
991
+ * Based on recent email operations in the database
992
+ */
993
+ export const checkRecipientRateLimit = zq({
994
+ args: z.object({
995
+ email: z.string().email(),
996
+ timeWindowMs: z.number(),
997
+ maxEmails: z.number(),
998
+ }),
999
+ returns: z.object({
1000
+ allowed: z.boolean(),
1001
+ count: z.number(),
1002
+ limit: z.number(),
1003
+ timeWindowMs: z.number(),
1004
+ retryAfter: z.number().optional(),
1005
+ }),
1006
+ handler: async (ctx, args) => {
1007
+ const cutoffTime = Date.now() - args.timeWindowMs;
1008
+
1009
+ const operations = await ctx.db
1010
+ .query("emailOperations")
1011
+ .withIndex("email", (q) => q.eq("email", args.email))
1012
+ .collect();
1013
+
1014
+ const recentOps = operations.filter(
1015
+ (op) => op.timestamp >= cutoffTime && op.success,
1016
+ );
1017
+
1018
+ const count = recentOps.length;
1019
+ const allowed = count < args.maxEmails;
1020
+
1021
+ let retryAfter: number | undefined;
1022
+ if (!allowed && recentOps.length > 0) {
1023
+ const oldestOp = recentOps.reduce((oldest, op) =>
1024
+ op.timestamp < oldest.timestamp ? op : oldest,
1025
+ );
1026
+ retryAfter = oldestOp.timestamp + args.timeWindowMs - Date.now();
1027
+ if (retryAfter < 0) retryAfter = 0;
1028
+ }
1029
+
1030
+ return {
1031
+ allowed,
1032
+ count,
1033
+ limit: args.maxEmails,
1034
+ timeWindowMs: args.timeWindowMs,
1035
+ retryAfter,
1036
+ };
1037
+ },
1038
+ });
1039
+
1040
+ /**
1041
+ * Rate limiting: Check if an actor/user can send more emails
1042
+ * Based on recent email operations in the database
1043
+ */
1044
+ export const checkActorRateLimit = zq({
1045
+ args: z.object({
1046
+ actorId: z.string(),
1047
+ timeWindowMs: z.number(),
1048
+ maxEmails: z.number(),
1049
+ }),
1050
+ returns: z.object({
1051
+ allowed: z.boolean(),
1052
+ count: z.number(),
1053
+ limit: z.number(),
1054
+ timeWindowMs: z.number(),
1055
+ retryAfter: z.number().optional(),
1056
+ }),
1057
+ handler: async (ctx, args) => {
1058
+ const cutoffTime = Date.now() - args.timeWindowMs;
1059
+
1060
+ const operations = await ctx.db
1061
+ .query("emailOperations")
1062
+ .withIndex("actorId", (q) => q.eq("actorId", args.actorId))
1063
+ .collect();
1064
+
1065
+ const recentOps = operations.filter(
1066
+ (op) => op.timestamp >= cutoffTime && op.success,
1067
+ );
1068
+
1069
+ const count = recentOps.length;
1070
+ const allowed = count < args.maxEmails;
1071
+
1072
+ let retryAfter: number | undefined;
1073
+ if (!allowed && recentOps.length > 0) {
1074
+ const oldestOp = recentOps.reduce((oldest, op) =>
1075
+ op.timestamp < oldest.timestamp ? op : oldest,
1076
+ );
1077
+ retryAfter = oldestOp.timestamp + args.timeWindowMs - Date.now();
1078
+ if (retryAfter < 0) retryAfter = 0;
1079
+ }
1080
+
1081
+ return {
1082
+ allowed,
1083
+ count,
1084
+ limit: args.maxEmails,
1085
+ timeWindowMs: args.timeWindowMs,
1086
+ retryAfter,
1087
+ };
1088
+ },
1089
+ });
1090
+
1091
+ /**
1092
+ * Rate limiting: Check global email sending rate
1093
+ * Checks total email operations across all senders
1094
+ */
1095
+ export const checkGlobalRateLimit = zq({
1096
+ args: z.object({
1097
+ timeWindowMs: z.number(),
1098
+ maxEmails: z.number(),
1099
+ }),
1100
+ returns: z.object({
1101
+ allowed: z.boolean(),
1102
+ count: z.number(),
1103
+ limit: z.number(),
1104
+ timeWindowMs: z.number(),
1105
+ }),
1106
+ handler: async (ctx, args) => {
1107
+ const cutoffTime = Date.now() - args.timeWindowMs;
1108
+
1109
+ const operations = await ctx.db
1110
+ .query("emailOperations")
1111
+ .withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
1112
+ .collect();
1113
+
1114
+ const recentOps = operations.filter((op) => op.success);
1115
+ const count = recentOps.length;
1116
+ const allowed = count < args.maxEmails;
1117
+
1118
+ return {
1119
+ allowed,
1120
+ count,
1121
+ limit: args.maxEmails,
1122
+ timeWindowMs: args.timeWindowMs,
1123
+ };
1124
+ },
1125
+ });