@devwithbobby/loops 0.1.12 → 0.1.14

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 (41) hide show
  1. package/dist/client/index.d.ts +305 -44
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js +21 -32
  4. package/dist/component/convex.config.d.ts +1 -1
  5. package/dist/component/convex.config.d.ts.map +1 -1
  6. package/dist/component/convex.config.js +1 -1
  7. package/dist/component/helpers.d.ts +7 -0
  8. package/dist/component/helpers.d.ts.map +1 -0
  9. package/dist/component/helpers.js +30 -0
  10. package/dist/component/http.d.ts +3 -0
  11. package/dist/component/http.d.ts.map +1 -0
  12. package/dist/component/http.js +268 -0
  13. package/dist/component/lib.d.ts +237 -22
  14. package/dist/component/lib.d.ts.map +1 -1
  15. package/dist/component/lib.js +91 -143
  16. package/dist/component/schema.d.ts +66 -1
  17. package/dist/component/schema.d.ts.map +1 -1
  18. package/dist/component/tables/contacts.d.ts +123 -1
  19. package/dist/component/tables/contacts.d.ts.map +1 -1
  20. package/dist/component/tables/emailOperations.d.ts +151 -1
  21. package/dist/component/tables/emailOperations.d.ts.map +1 -1
  22. package/dist/component/tables/emailOperations.js +1 -6
  23. package/dist/component/validators.d.ts +20 -3
  24. package/dist/component/validators.d.ts.map +1 -1
  25. package/dist/types.d.ts +97 -0
  26. package/dist/types.d.ts.map +1 -0
  27. package/dist/types.js +2 -0
  28. package/dist/utils.d.ts +186 -3
  29. package/dist/utils.d.ts.map +1 -1
  30. package/package.json +101 -101
  31. package/src/client/index.ts +40 -52
  32. package/src/component/_generated/api.d.ts +3 -11
  33. package/src/component/convex.config.ts +7 -2
  34. package/src/component/helpers.ts +44 -0
  35. package/src/component/http.ts +304 -0
  36. package/src/component/lib.ts +189 -204
  37. package/src/component/tables/contacts.ts +0 -1
  38. package/src/component/tables/emailOperations.ts +1 -7
  39. package/src/component/validators.ts +0 -1
  40. package/src/types.ts +168 -0
  41. package/src/client/types.ts +0 -64
@@ -1,29 +1,10 @@
1
1
  import { z } from "zod";
2
+ import { internalLib } from "../types";
2
3
  import { za, zm, zq } from "../utils.js";
3
- import { internal } from "./_generated/api";
4
+ import type { Doc } from "./_generated/dataModel.js";
5
+ import { loopsFetch, sanitizeLoopsError } from "./helpers";
4
6
  import { contactValidator } from "./validators.js";
5
7
 
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
8
  /**
28
9
  * Internal mutation to store/update a contact in the database
29
10
  */
@@ -112,22 +93,24 @@ export const logEmailOperation = zm({
112
93
  }),
113
94
  returns: z.void(),
114
95
  handler: async (ctx, args) => {
115
- const operationData: Record<string, any> = {
96
+ const operationData: Omit<
97
+ Doc<"emailOperations">,
98
+ "_id" | "_creationTime"
99
+ > = {
116
100
  operationType: args.operationType,
117
101
  email: args.email,
118
102
  timestamp: Date.now(),
119
103
  success: args.success,
104
+ actorId: args.actorId,
105
+ transactionalId: args.transactionalId,
106
+ campaignId: args.campaignId,
107
+ loopId: args.loopId,
108
+ eventName: args.eventName,
109
+ messageId: args.messageId,
110
+ metadata: args.metadata,
120
111
  };
121
112
 
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);
113
+ await ctx.db.insert("emailOperations", operationData);
131
114
  },
132
115
  });
133
116
 
@@ -143,7 +126,7 @@ export const countContacts = zq({
143
126
  }),
144
127
  returns: z.number(),
145
128
  handler: async (ctx, args) => {
146
- let contacts;
129
+ let contacts: Doc<"contacts">[];
147
130
  if (args.userGroup !== undefined) {
148
131
  contacts = await ctx.db
149
132
  .query("contacts")
@@ -212,8 +195,8 @@ export const listContacts = zq({
212
195
  hasMore: z.boolean(),
213
196
  }),
214
197
  handler: async (ctx, args) => {
215
- let allContacts;
216
-
198
+ let allContacts: Doc<"contacts">[];
199
+
217
200
  // Get all contacts matching the filters
218
201
  if (args.userGroup !== undefined) {
219
202
  allContacts = await ctx.db
@@ -249,7 +232,12 @@ export const listContacts = zq({
249
232
  allContacts.sort((a, b) => b.createdAt - a.createdAt);
250
233
 
251
234
  const total = allContacts.length;
252
- const paginatedContacts = allContacts.slice(args.offset, args.offset + args.limit);
235
+ const paginatedContacts = allContacts
236
+ .slice(args.offset, args.offset + args.limit)
237
+ .map((contact) => ({
238
+ ...contact,
239
+ subscribed: contact.subscribed ?? true, // Ensure subscribed is always boolean
240
+ }));
253
241
  const hasMore = args.offset + args.limit < total;
254
242
 
255
243
  return {
@@ -277,58 +265,57 @@ export const addContact = za({
277
265
  id: z.string().optional(),
278
266
  }),
279
267
  handler: async (ctx, args) => {
280
- const response = await fetch(`${LOOPS_API_BASE_URL}/contacts/create`, {
268
+ const createResponse = await loopsFetch(args.apiKey, "/contacts/create", {
281
269
  method: "POST",
282
- headers: {
283
- Authorization: `Bearer ${args.apiKey}`,
284
- "Content-Type": "application/json",
285
- },
286
- body: JSON.stringify(args.contact),
270
+ json: args.contact,
287
271
  });
288
272
 
289
- if (!response.ok) {
290
- const errorText = await response.text();
291
-
292
- if (response.status === 409) {
293
- console.log(`Contact ${args.contact.email} already exists, updating instead`);
294
-
295
- const findResponse = await fetch(
296
- `${LOOPS_API_BASE_URL}/contacts/find?email=${encodeURIComponent(args.contact.email)}`,
297
- {
298
- method: "GET",
299
- headers: {
300
- Authorization: `Bearer ${args.apiKey}`,
301
- "Content-Type": "application/json",
302
- },
303
- }
273
+ if (!createResponse.ok) {
274
+ const errorText = await createResponse.text();
275
+
276
+ if (createResponse.status === 409) {
277
+ console.log(
278
+ `Contact ${args.contact.email} already exists, updating instead`,
279
+ );
280
+
281
+ const findResponse = await loopsFetch(
282
+ args.apiKey,
283
+ `/contacts/find?email=${encodeURIComponent(args.contact.email)}`,
284
+ { method: "GET" },
304
285
  );
305
286
 
306
287
  if (!findResponse.ok) {
307
288
  const findErrorText = await findResponse.text();
308
- console.error(`Failed to find existing contact [${findResponse.status}]:`, findErrorText);
289
+ console.error(
290
+ `Failed to find existing contact [${findResponse.status}]:`,
291
+ findErrorText,
292
+ );
309
293
  }
310
294
 
311
- const updateResponse = await fetch(`${LOOPS_API_BASE_URL}/contacts/update`, {
312
- method: "PUT",
313
- headers: {
314
- Authorization: `Bearer ${args.apiKey}`,
315
- "Content-Type": "application/json",
295
+ const updateResponse = await loopsFetch(
296
+ args.apiKey,
297
+ "/contacts/update",
298
+ {
299
+ method: "PUT",
300
+ json: {
301
+ email: args.contact.email,
302
+ firstName: args.contact.firstName,
303
+ lastName: args.contact.lastName,
304
+ userId: args.contact.userId,
305
+ source: args.contact.source,
306
+ subscribed: args.contact.subscribed,
307
+ userGroup: args.contact.userGroup,
308
+ },
316
309
  },
317
- body: JSON.stringify({
318
- email: args.contact.email,
319
- firstName: args.contact.firstName,
320
- lastName: args.contact.lastName,
321
- userId: args.contact.userId,
322
- source: args.contact.source,
323
- subscribed: args.contact.subscribed,
324
- userGroup: args.contact.userGroup,
325
- }),
326
- });
310
+ );
327
311
 
328
312
  if (!updateResponse.ok) {
329
313
  const updateErrorText = await updateResponse.text();
330
- console.error(`Loops API error [${updateResponse.status}]:`, updateErrorText);
331
- throw sanitizeError(updateResponse.status, updateErrorText);
314
+ console.error(
315
+ `Loops API error [${updateResponse.status}]:`,
316
+ updateErrorText,
317
+ );
318
+ throw sanitizeLoopsError(updateResponse.status, updateErrorText);
332
319
  }
333
320
 
334
321
  // Get contact ID if available
@@ -339,7 +326,7 @@ export const addContact = za({
339
326
  }
340
327
 
341
328
  // Store/update in our database
342
- await ctx.runMutation(((internal as any).lib).storeContact as any, {
329
+ await ctx.runMutation(internalLib.storeContact, {
343
330
  email: args.contact.email,
344
331
  firstName: args.contact.firstName,
345
332
  lastName: args.contact.lastName,
@@ -356,15 +343,14 @@ export const addContact = za({
356
343
  };
357
344
  }
358
345
 
359
- // For other errors, throw as normal
360
- console.error(`Loops API error [${response.status}]:`, errorText);
361
- throw sanitizeError(response.status, errorText);
346
+ console.error(`Loops API error [${createResponse.status}]:`, errorText);
347
+ throw sanitizeLoopsError(createResponse.status, errorText);
362
348
  }
363
349
 
364
350
  // Contact was created successfully
365
- const data = (await response.json()) as { id?: string };
351
+ const data = (await createResponse.json()) as { id?: string };
366
352
 
367
- await ctx.runMutation(((internal as any).lib).storeContact as any, {
353
+ await ctx.runMutation(internalLib.storeContact, {
368
354
  email: args.contact.email,
369
355
  firstName: args.contact.firstName,
370
356
  lastName: args.contact.lastName,
@@ -401,13 +387,9 @@ export const updateContact = za({
401
387
  success: z.boolean(),
402
388
  }),
403
389
  handler: async (ctx, args) => {
404
- const response = await fetch(`${LOOPS_API_BASE_URL}/contacts/update`, {
390
+ const response = await loopsFetch(args.apiKey, "/contacts/update", {
405
391
  method: "PUT",
406
- headers: {
407
- Authorization: `Bearer ${args.apiKey}`,
408
- "Content-Type": "application/json",
409
- },
410
- body: JSON.stringify({
392
+ json: {
411
393
  email: args.email,
412
394
  dataVariables: args.dataVariables,
413
395
  firstName: args.firstName,
@@ -416,16 +398,16 @@ export const updateContact = za({
416
398
  source: args.source,
417
399
  subscribed: args.subscribed,
418
400
  userGroup: args.userGroup,
419
- }),
401
+ },
420
402
  });
421
403
 
422
404
  if (!response.ok) {
423
405
  const errorText = await response.text();
424
406
  console.error(`Loops API error [${response.status}]:`, errorText);
425
- throw sanitizeError(response.status, errorText);
407
+ throw sanitizeLoopsError(response.status, errorText);
426
408
  }
427
409
 
428
- await ctx.runMutation(((internal as any).lib).storeContact as any, {
410
+ await ctx.runMutation(internalLib.storeContact, {
429
411
  email: args.email,
430
412
  firstName: args.firstName,
431
413
  lastName: args.lastName,
@@ -454,35 +436,31 @@ export const sendTransactional = za({
454
436
  messageId: z.string().optional(),
455
437
  }),
456
438
  handler: async (ctx, args) => {
457
- const response = await fetch(`${LOOPS_API_BASE_URL}/transactional`, {
439
+ const response = await loopsFetch(args.apiKey, "/transactional", {
458
440
  method: "POST",
459
- headers: {
460
- Authorization: `Bearer ${args.apiKey}`,
461
- "Content-Type": "application/json",
462
- },
463
- body: JSON.stringify({
441
+ json: {
464
442
  transactionalId: args.transactionalId,
465
443
  email: args.email,
466
444
  dataVariables: args.dataVariables,
467
- }),
445
+ },
468
446
  });
469
447
 
470
448
  if (!response.ok) {
471
449
  const errorText = await response.text();
472
450
  console.error(`Loops API error [${response.status}]:`, errorText);
473
- await ctx.runMutation(((internal as any).lib).logEmailOperation as any, {
451
+ await ctx.runMutation(internalLib.logEmailOperation, {
474
452
  operationType: "transactional",
475
453
  email: args.email,
476
454
  success: false,
477
455
  transactionalId: args.transactionalId,
478
456
  });
479
-
480
- throw sanitizeError(response.status, errorText);
457
+
458
+ throw sanitizeLoopsError(response.status, errorText);
481
459
  }
482
460
 
483
461
  const data = (await response.json()) as { messageId?: string };
484
462
 
485
- await ctx.runMutation(((internal as any).lib).logEmailOperation as any, {
463
+ await ctx.runMutation(internalLib.logEmailOperation, {
486
464
  operationType: "transactional",
487
465
  email: args.email,
488
466
  success: true,
@@ -510,24 +488,20 @@ export const sendEvent = za({
510
488
  returns: z.object({
511
489
  success: z.boolean(),
512
490
  }),
513
- handler: async (ctx, args) => {
514
- const response = await fetch(`${LOOPS_API_BASE_URL}/events/send`, {
491
+ handler: async (_ctx, args) => {
492
+ const response = await loopsFetch(args.apiKey, "/events/send", {
515
493
  method: "POST",
516
- headers: {
517
- Authorization: `Bearer ${args.apiKey}`,
518
- "Content-Type": "application/json",
519
- },
520
- body: JSON.stringify({
494
+ json: {
521
495
  email: args.email,
522
496
  eventName: args.eventName,
523
497
  eventProperties: args.eventProperties,
524
- }),
498
+ },
525
499
  });
526
500
 
527
501
  if (!response.ok) {
528
502
  const errorText = await response.text();
529
503
  console.error(`Loops API error [${response.status}]:`, errorText);
530
- throw sanitizeError(response.status, errorText);
504
+ throw sanitizeLoopsError(response.status, errorText);
531
505
  }
532
506
 
533
507
  return { success: true };
@@ -546,22 +520,18 @@ export const deleteContact = za({
546
520
  success: z.boolean(),
547
521
  }),
548
522
  handler: async (ctx, args) => {
549
- const response = await fetch(`${LOOPS_API_BASE_URL}/contacts/delete`, {
523
+ const response = await loopsFetch(args.apiKey, "/contacts/delete", {
550
524
  method: "POST",
551
- headers: {
552
- Authorization: `Bearer ${args.apiKey}`,
553
- "Content-Type": "application/json",
554
- },
555
- body: JSON.stringify({ email: args.email }),
525
+ json: { email: args.email },
556
526
  });
557
527
 
558
528
  if (!response.ok) {
559
529
  const errorText = await response.text();
560
530
  console.error(`Loops API error [${response.status}]:`, errorText);
561
- throw sanitizeError(response.status, errorText);
531
+ throw sanitizeLoopsError(response.status, errorText);
562
532
  }
563
533
 
564
- await ctx.runMutation(((internal as any).lib).removeContact as any, {
534
+ await ctx.runMutation(internalLib.removeContact, {
565
535
  email: args.email,
566
536
  });
567
537
 
@@ -574,15 +544,15 @@ export const deleteContact = za({
574
544
  * Note: Loops in Loops.so are triggered through events, not a direct API endpoint.
575
545
  * This function uses the events endpoint to trigger the loop.
576
546
  * The loop must be configured in the Loops dashboard to listen for events.
577
- *
547
+ *
578
548
  * IMPORTANT: Loops.so doesn't have a direct /loops/trigger endpoint.
579
549
  * Loops are triggered by sending events. Make sure your loop in the dashboard
580
550
  * is configured to trigger on an event name (e.g., "loop_trigger").
581
- *
551
+ *
582
552
  * If you need to trigger a specific loop, you should:
583
553
  * 1. Configure the loop in the dashboard to listen for a specific event name
584
554
  * 2. Use sendEvent() with that event name instead
585
- *
555
+ *
586
556
  * This function is kept for backwards compatibility but works by sending an event.
587
557
  */
588
558
  export const triggerLoop = za({
@@ -602,10 +572,10 @@ export const triggerLoop = za({
602
572
  // Loops are triggered through events. We'll use the events endpoint.
603
573
  // Default event name if not provided
604
574
  const eventName = args.eventName || `loop_${args.loopId}`;
605
-
575
+
606
576
  try {
607
577
  // Send event to trigger the loop
608
- await ctx.runAction(((internal as any).lib).sendEvent as any, {
578
+ await ctx.runAction(internalLib.sendEvent, {
609
579
  apiKey: args.apiKey,
610
580
  email: args.email,
611
581
  eventName,
@@ -616,7 +586,7 @@ export const triggerLoop = za({
616
586
  });
617
587
 
618
588
  // Log as loop operation
619
- await ctx.runMutation(((internal as any).lib).logEmailOperation as any, {
589
+ await ctx.runMutation(internalLib.logEmailOperation, {
620
590
  operationType: "loop",
621
591
  email: args.email,
622
592
  success: true,
@@ -626,19 +596,22 @@ export const triggerLoop = za({
626
596
 
627
597
  return {
628
598
  success: true,
629
- warning: "Loops are triggered via events. Ensure your loop is configured to listen for this event.",
599
+ warning:
600
+ "Loops are triggered via events. Ensure your loop is configured to listen for this event.",
630
601
  };
631
602
  } catch (error) {
632
603
  // Log failed loop operation
633
- await ctx.runMutation(((internal as any).lib).logEmailOperation as any, {
604
+ await ctx.runMutation(internalLib.logEmailOperation, {
634
605
  operationType: "loop",
635
606
  email: args.email,
636
607
  success: false,
637
608
  loopId: args.loopId,
638
609
  eventName,
639
- metadata: { error: error instanceof Error ? error.message : String(error) },
610
+ metadata: {
611
+ error: error instanceof Error ? error.message : String(error),
612
+ },
640
613
  });
641
-
614
+
642
615
  throw error;
643
616
  }
644
617
  },
@@ -670,16 +643,11 @@ export const findContact = za({
670
643
  })
671
644
  .optional(),
672
645
  }),
673
- handler: async (ctx, args) => {
674
- const response = await fetch(
675
- `${LOOPS_API_BASE_URL}/contacts/find?email=${encodeURIComponent(args.email)}`,
676
- {
677
- method: "GET",
678
- headers: {
679
- Authorization: `Bearer ${args.apiKey}`,
680
- "Content-Type": "application/json",
681
- },
682
- },
646
+ handler: async (_ctx, args) => {
647
+ const response = await loopsFetch(
648
+ args.apiKey,
649
+ `/contacts/find?email=${encodeURIComponent(args.email)}`,
650
+ { method: "GET" },
683
651
  );
684
652
 
685
653
  if (!response.ok) {
@@ -688,10 +656,13 @@ export const findContact = za({
688
656
  }
689
657
  const errorText = await response.text();
690
658
  console.error(`Loops API error [${response.status}]:`, errorText);
691
- throw sanitizeError(response.status, errorText);
659
+ throw sanitizeLoopsError(response.status, errorText);
692
660
  }
693
661
 
694
- const data = (await response.json()) as Record<string, any> | Array<Record<string, any>>;
662
+ type LoopsContactRecord = Record<string, unknown>;
663
+ const data = (await response.json()) as
664
+ | LoopsContactRecord
665
+ | Array<LoopsContactRecord>;
695
666
 
696
667
  // Handle case where Loops returns an array instead of a single object
697
668
  let contact = Array.isArray(data) ? data[0] : data;
@@ -699,13 +670,16 @@ export const findContact = za({
699
670
  // Convert null values to undefined for optional fields (Zod handles undefined but not null in optional())
700
671
  if (contact) {
701
672
  contact = Object.fromEntries(
702
- Object.entries(contact).map(([key, value]) => [key, value === null ? undefined : value])
703
- ) as Record<string, any>;
673
+ Object.entries(contact).map(([key, value]) => [
674
+ key,
675
+ value === null ? undefined : value,
676
+ ]),
677
+ ) as LoopsContactRecord;
704
678
  }
705
679
 
706
680
  return {
707
681
  success: true,
708
- contact: contact as Record<string, any> | undefined,
682
+ contact: contact as LoopsContactRecord | undefined,
709
683
  };
710
684
  },
711
685
  });
@@ -724,22 +698,27 @@ export const batchCreateContacts = za({
724
698
  success: z.boolean(),
725
699
  created: z.number().optional(),
726
700
  failed: z.number().optional(),
727
- results: z.array(z.object({
728
- email: z.string(),
729
- success: z.boolean(),
730
- error: z.string().optional(),
731
- })).optional(),
701
+ results: z
702
+ .array(
703
+ z.object({
704
+ email: z.string(),
705
+ success: z.boolean(),
706
+ error: z.string().optional(),
707
+ }),
708
+ )
709
+ .optional(),
732
710
  }),
733
711
  handler: async (ctx, args) => {
734
712
  let created = 0;
735
713
  let failed = 0;
736
- const results: Array<{ email: string; success: boolean; error?: string }> = [];
714
+ const results: Array<{ email: string; success: boolean; error?: string }> =
715
+ [];
737
716
 
738
717
  // Create contacts one by one since Loops.so doesn't have a batch endpoint
739
718
  for (const contact of args.contacts) {
740
719
  try {
741
720
  // Use the addContact function which handles create/update logic
742
- const result = await ctx.runAction(((internal as any).lib).addContact as any, {
721
+ const result = await ctx.runAction(internalLib.addContact, {
743
722
  apiKey: args.apiKey,
744
723
  contact,
745
724
  });
@@ -749,10 +728,10 @@ export const batchCreateContacts = za({
749
728
  results.push({ email: contact.email, success: true });
750
729
  } else {
751
730
  failed++;
752
- results.push({
753
- email: contact.email,
754
- success: false,
755
- error: "Unknown error"
731
+ results.push({
732
+ email: contact.email,
733
+ success: false,
734
+ error: "Unknown error",
756
735
  });
757
736
  }
758
737
  } catch (error) {
@@ -787,22 +766,18 @@ export const unsubscribeContact = za({
787
766
  success: z.boolean(),
788
767
  }),
789
768
  handler: async (ctx, args) => {
790
- const response = await fetch(`${LOOPS_API_BASE_URL}/contacts/unsubscribe`, {
769
+ const response = await loopsFetch(args.apiKey, "/contacts/unsubscribe", {
791
770
  method: "POST",
792
- headers: {
793
- Authorization: `Bearer ${args.apiKey}`,
794
- "Content-Type": "application/json",
795
- },
796
- body: JSON.stringify({ email: args.email }),
771
+ json: { email: args.email },
797
772
  });
798
773
 
799
774
  if (!response.ok) {
800
775
  const errorText = await response.text();
801
776
  console.error(`Loops API error [${response.status}]:`, errorText);
802
- throw sanitizeError(response.status, errorText);
777
+ throw sanitizeLoopsError(response.status, errorText);
803
778
  }
804
779
 
805
- await ctx.runMutation(((internal as any).lib).storeContact as any, {
780
+ await ctx.runMutation(internalLib.storeContact, {
806
781
  email: args.email,
807
782
  subscribed: false,
808
783
  });
@@ -824,22 +799,18 @@ export const resubscribeContact = za({
824
799
  success: z.boolean(),
825
800
  }),
826
801
  handler: async (ctx, args) => {
827
- const response = await fetch(`${LOOPS_API_BASE_URL}/contacts/resubscribe`, {
802
+ const response = await loopsFetch(args.apiKey, "/contacts/resubscribe", {
828
803
  method: "POST",
829
- headers: {
830
- Authorization: `Bearer ${args.apiKey}`,
831
- "Content-Type": "application/json",
832
- },
833
- body: JSON.stringify({ email: args.email }),
804
+ json: { email: args.email },
834
805
  });
835
806
 
836
807
  if (!response.ok) {
837
808
  const errorText = await response.text();
838
809
  console.error(`Loops API error [${response.status}]:`, errorText);
839
- throw sanitizeError(response.status, errorText);
810
+ throw sanitizeLoopsError(response.status, errorText);
840
811
  }
841
812
 
842
- await ctx.runMutation(((internal as any).lib).storeContact as any, {
813
+ await ctx.runMutation(internalLib.storeContact, {
843
814
  email: args.email,
844
815
  subscribed: true,
845
816
  });
@@ -858,15 +829,17 @@ export const detectRecipientSpam = zq({
858
829
  maxEmailsPerRecipient: z.number().default(10),
859
830
  }),
860
831
  returns: z.array(
861
- z.object({
862
- email: z.string(),
863
- count: z.number(),
864
- timeWindowMs: z.number(),
865
- }),
832
+ z
833
+ .object({
834
+ email: z.string(),
835
+ count: z.number(),
836
+ timeWindowMs: z.number(),
837
+ })
838
+ .catchall(z.any()),
866
839
  ),
867
840
  handler: async (ctx, args) => {
868
841
  const cutoffTime = Date.now() - args.timeWindowMs;
869
-
842
+
870
843
  const operations = await ctx.db
871
844
  .query("emailOperations")
872
845
  .withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
@@ -879,7 +852,11 @@ export const detectRecipientSpam = zq({
879
852
  }
880
853
  }
881
854
 
882
- const suspicious: Array<{ email: string; count: number; timeWindowMs: number }> = [];
855
+ const suspicious: Array<{
856
+ email: string;
857
+ count: number;
858
+ timeWindowMs: number;
859
+ }> = [];
883
860
  for (const [email, count] of emailCounts.entries()) {
884
861
  if (count > args.maxEmailsPerRecipient) {
885
862
  suspicious.push({
@@ -912,7 +889,7 @@ export const detectActorSpam = zq({
912
889
  ),
913
890
  handler: async (ctx, args) => {
914
891
  const cutoffTime = Date.now() - args.timeWindowMs;
915
-
892
+
916
893
  const operations = await ctx.db
917
894
  .query("emailOperations")
918
895
  .withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
@@ -925,7 +902,11 @@ export const detectActorSpam = zq({
925
902
  }
926
903
  }
927
904
 
928
- const suspicious: Array<{ actorId: string; count: number; timeWindowMs: number }> = [];
905
+ const suspicious: Array<{
906
+ actorId: string;
907
+ count: number;
908
+ timeWindowMs: number;
909
+ }> = [];
929
910
  for (const [actorId, count] of actorCounts.entries()) {
930
911
  if (count > args.maxEmailsPerActor) {
931
912
  suspicious.push({
@@ -947,17 +928,19 @@ export const getEmailStats = zq({
947
928
  args: z.object({
948
929
  timeWindowMs: z.number().default(86400000),
949
930
  }),
950
- returns: z.object({
951
- totalOperations: z.number(),
952
- successfulOperations: z.number(),
953
- failedOperations: z.number(),
954
- operationsByType: z.record(z.string(), z.number()),
955
- uniqueRecipients: z.number(),
956
- uniqueActors: z.number(),
957
- }),
931
+ returns: z
932
+ .object({
933
+ totalOperations: z.number(),
934
+ successfulOperations: z.number(),
935
+ failedOperations: z.number(),
936
+ operationsByType: z.record(z.string(), z.number()),
937
+ uniqueRecipients: z.number(),
938
+ uniqueActors: z.number(),
939
+ })
940
+ .catchall(z.any()),
958
941
  handler: async (ctx, args) => {
959
942
  const cutoffTime = Date.now() - args.timeWindowMs;
960
-
943
+
961
944
  const operations = await ctx.db
962
945
  .query("emailOperations")
963
946
  .withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
@@ -975,11 +958,11 @@ export const getEmailStats = zq({
975
958
  for (const op of operations) {
976
959
  stats.operationsByType[op.operationType] =
977
960
  (stats.operationsByType[op.operationType] ?? 0) + 1;
978
-
961
+
979
962
  if (op.email && op.email !== "audience") {
980
963
  stats.uniqueRecipients.add(op.email);
981
964
  }
982
-
965
+
983
966
  if (op.actorId) {
984
967
  stats.uniqueActors.add(op.actorId);
985
968
  }
@@ -1014,7 +997,7 @@ export const detectRapidFirePatterns = zq({
1014
997
  ),
1015
998
  handler: async (ctx, args) => {
1016
999
  const cutoffTime = Date.now() - args.timeWindowMs;
1017
-
1000
+
1018
1001
  const operations = await ctx.db
1019
1002
  .query("emailOperations")
1020
1003
  .withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
@@ -1037,7 +1020,7 @@ export const detectRapidFirePatterns = zq({
1037
1020
  if (!emailGroups.has(op.email)) {
1038
1021
  emailGroups.set(op.email, []);
1039
1022
  }
1040
- emailGroups.get(op.email)!.push(op);
1023
+ emailGroups.get(op.email)?.push(op);
1041
1024
  }
1042
1025
  }
1043
1026
 
@@ -1045,13 +1028,13 @@ export const detectRapidFirePatterns = zq({
1045
1028
  for (let i = 0; i < ops.length; i++) {
1046
1029
  const op = ops[i];
1047
1030
  if (!op) continue;
1048
-
1031
+
1049
1032
  const windowStart = op.timestamp;
1050
1033
  const windowEnd = windowStart + args.timeWindowMs;
1051
1034
  const opsInWindow = ops.filter(
1052
1035
  (op) => op.timestamp >= windowStart && op.timestamp <= windowEnd,
1053
1036
  );
1054
-
1037
+
1055
1038
  if (opsInWindow.length >= args.minEmailsInWindow) {
1056
1039
  patterns.push({
1057
1040
  email,
@@ -1070,7 +1053,7 @@ export const detectRapidFirePatterns = zq({
1070
1053
  if (!actorGroups.has(op.actorId)) {
1071
1054
  actorGroups.set(op.actorId, []);
1072
1055
  }
1073
- actorGroups.get(op.actorId)!.push(op);
1056
+ actorGroups.get(op.actorId)?.push(op);
1074
1057
  }
1075
1058
  }
1076
1059
 
@@ -1078,13 +1061,13 @@ export const detectRapidFirePatterns = zq({
1078
1061
  for (let i = 0; i < ops.length; i++) {
1079
1062
  const op = ops[i];
1080
1063
  if (!op) continue;
1081
-
1064
+
1082
1065
  const windowStart = op.timestamp;
1083
1066
  const windowEnd = windowStart + args.timeWindowMs;
1084
1067
  const opsInWindow = ops.filter(
1085
1068
  (op) => op.timestamp >= windowStart && op.timestamp <= windowEnd,
1086
1069
  );
1087
-
1070
+
1088
1071
  if (opsInWindow.length >= args.minEmailsInWindow) {
1089
1072
  patterns.push({
1090
1073
  actorId,
@@ -1111,13 +1094,15 @@ export const checkRecipientRateLimit = zq({
1111
1094
  timeWindowMs: z.number(),
1112
1095
  maxEmails: z.number(),
1113
1096
  }),
1114
- returns: z.object({
1115
- allowed: z.boolean(),
1116
- count: z.number(),
1117
- limit: z.number(),
1118
- timeWindowMs: z.number(),
1119
- retryAfter: z.number().optional(),
1120
- }),
1097
+ returns: z
1098
+ .object({
1099
+ allowed: z.boolean(),
1100
+ count: z.number(),
1101
+ limit: z.number(),
1102
+ timeWindowMs: z.number(),
1103
+ retryAfter: z.number().optional(),
1104
+ })
1105
+ .catchall(z.any()),
1121
1106
  handler: async (ctx, args) => {
1122
1107
  const cutoffTime = Date.now() - args.timeWindowMs;
1123
1108