@devwithbobby/loops 0.1.12 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/index.d.ts +110 -37
- package/dist/client/index.d.ts.map +1 -1
- package/dist/component/convex.config.d.ts +1 -1
- package/dist/component/convex.config.d.ts.map +1 -1
- package/dist/component/lib.d.ts +237 -22
- package/dist/component/lib.d.ts.map +1 -1
- package/dist/component/lib.js +47 -29
- package/dist/component/schema.d.ts +66 -1
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/tables/contacts.d.ts +123 -1
- package/dist/component/tables/contacts.d.ts.map +1 -1
- package/dist/component/tables/emailOperations.d.ts +151 -1
- package/dist/component/tables/emailOperations.d.ts.map +1 -1
- package/dist/component/tables/emailOperations.js +1 -6
- package/dist/component/validators.d.ts +20 -3
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/utils.d.ts +186 -3
- package/dist/utils.d.ts.map +1 -1
- package/package.json +101 -101
- package/src/client/index.ts +31 -16
- package/src/component/lib.ts +146 -98
- package/src/component/tables/contacts.ts +0 -1
- package/src/component/tables/emailOperations.ts +1 -7
- package/src/component/validators.ts +0 -1
package/src/component/lib.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { za, zm, zq } from "../utils.js";
|
|
3
3
|
import { internal } from "./_generated/api";
|
|
4
|
+
import type { Doc } from "./_generated/dataModel.js";
|
|
4
5
|
import { contactValidator } from "./validators.js";
|
|
5
6
|
|
|
6
7
|
const LOOPS_API_BASE_URL = "https://app.loops.so/api/v1";
|
|
@@ -8,7 +9,7 @@ const LOOPS_API_BASE_URL = "https://app.loops.so/api/v1";
|
|
|
8
9
|
/**
|
|
9
10
|
* Sanitize error messages to avoid leaking sensitive information
|
|
10
11
|
*/
|
|
11
|
-
const sanitizeError = (status: number,
|
|
12
|
+
const sanitizeError = (status: number, _errorText: string): Error => {
|
|
12
13
|
if (status === 401 || status === 403) {
|
|
13
14
|
return new Error("Authentication failed. Please check your API key.");
|
|
14
15
|
}
|
|
@@ -120,7 +121,8 @@ export const logEmailOperation = zm({
|
|
|
120
121
|
};
|
|
121
122
|
|
|
122
123
|
if (args.actorId) operationData.actorId = args.actorId;
|
|
123
|
-
if (args.transactionalId)
|
|
124
|
+
if (args.transactionalId)
|
|
125
|
+
operationData.transactionalId = args.transactionalId;
|
|
124
126
|
if (args.campaignId) operationData.campaignId = args.campaignId;
|
|
125
127
|
if (args.loopId) operationData.loopId = args.loopId;
|
|
126
128
|
if (args.eventName) operationData.eventName = args.eventName;
|
|
@@ -143,7 +145,7 @@ export const countContacts = zq({
|
|
|
143
145
|
}),
|
|
144
146
|
returns: z.number(),
|
|
145
147
|
handler: async (ctx, args) => {
|
|
146
|
-
let contacts;
|
|
148
|
+
let contacts: Doc<"contacts">[];
|
|
147
149
|
if (args.userGroup !== undefined) {
|
|
148
150
|
contacts = await ctx.db
|
|
149
151
|
.query("contacts")
|
|
@@ -212,8 +214,8 @@ export const listContacts = zq({
|
|
|
212
214
|
hasMore: z.boolean(),
|
|
213
215
|
}),
|
|
214
216
|
handler: async (ctx, args) => {
|
|
215
|
-
let allContacts;
|
|
216
|
-
|
|
217
|
+
let allContacts: Doc<"contacts">[];
|
|
218
|
+
|
|
217
219
|
// Get all contacts matching the filters
|
|
218
220
|
if (args.userGroup !== undefined) {
|
|
219
221
|
allContacts = await ctx.db
|
|
@@ -249,7 +251,12 @@ export const listContacts = zq({
|
|
|
249
251
|
allContacts.sort((a, b) => b.createdAt - a.createdAt);
|
|
250
252
|
|
|
251
253
|
const total = allContacts.length;
|
|
252
|
-
const paginatedContacts = allContacts
|
|
254
|
+
const paginatedContacts = allContacts
|
|
255
|
+
.slice(args.offset, args.offset + args.limit)
|
|
256
|
+
.map((contact) => ({
|
|
257
|
+
...contact,
|
|
258
|
+
subscribed: contact.subscribed ?? true, // Ensure subscribed is always boolean
|
|
259
|
+
}));
|
|
253
260
|
const hasMore = args.offset + args.limit < total;
|
|
254
261
|
|
|
255
262
|
return {
|
|
@@ -288,10 +295,12 @@ export const addContact = za({
|
|
|
288
295
|
|
|
289
296
|
if (!response.ok) {
|
|
290
297
|
const errorText = await response.text();
|
|
291
|
-
|
|
298
|
+
|
|
292
299
|
if (response.status === 409) {
|
|
293
|
-
console.log(
|
|
294
|
-
|
|
300
|
+
console.log(
|
|
301
|
+
`Contact ${args.contact.email} already exists, updating instead`,
|
|
302
|
+
);
|
|
303
|
+
|
|
295
304
|
const findResponse = await fetch(
|
|
296
305
|
`${LOOPS_API_BASE_URL}/contacts/find?email=${encodeURIComponent(args.contact.email)}`,
|
|
297
306
|
{
|
|
@@ -300,34 +309,43 @@ export const addContact = za({
|
|
|
300
309
|
Authorization: `Bearer ${args.apiKey}`,
|
|
301
310
|
"Content-Type": "application/json",
|
|
302
311
|
},
|
|
303
|
-
}
|
|
312
|
+
},
|
|
304
313
|
);
|
|
305
314
|
|
|
306
315
|
if (!findResponse.ok) {
|
|
307
316
|
const findErrorText = await findResponse.text();
|
|
308
|
-
console.error(
|
|
317
|
+
console.error(
|
|
318
|
+
`Failed to find existing contact [${findResponse.status}]:`,
|
|
319
|
+
findErrorText,
|
|
320
|
+
);
|
|
309
321
|
}
|
|
310
322
|
|
|
311
|
-
const updateResponse = await fetch(
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
323
|
+
const updateResponse = await fetch(
|
|
324
|
+
`${LOOPS_API_BASE_URL}/contacts/update`,
|
|
325
|
+
{
|
|
326
|
+
method: "PUT",
|
|
327
|
+
headers: {
|
|
328
|
+
Authorization: `Bearer ${args.apiKey}`,
|
|
329
|
+
"Content-Type": "application/json",
|
|
330
|
+
},
|
|
331
|
+
body: JSON.stringify({
|
|
332
|
+
email: args.contact.email,
|
|
333
|
+
firstName: args.contact.firstName,
|
|
334
|
+
lastName: args.contact.lastName,
|
|
335
|
+
userId: args.contact.userId,
|
|
336
|
+
source: args.contact.source,
|
|
337
|
+
subscribed: args.contact.subscribed,
|
|
338
|
+
userGroup: args.contact.userGroup,
|
|
339
|
+
}),
|
|
316
340
|
},
|
|
317
|
-
|
|
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
|
-
});
|
|
341
|
+
);
|
|
327
342
|
|
|
328
343
|
if (!updateResponse.ok) {
|
|
329
344
|
const updateErrorText = await updateResponse.text();
|
|
330
|
-
console.error(
|
|
345
|
+
console.error(
|
|
346
|
+
`Loops API error [${updateResponse.status}]:`,
|
|
347
|
+
updateErrorText,
|
|
348
|
+
);
|
|
331
349
|
throw sanitizeError(updateResponse.status, updateErrorText);
|
|
332
350
|
}
|
|
333
351
|
|
|
@@ -339,7 +357,7 @@ export const addContact = za({
|
|
|
339
357
|
}
|
|
340
358
|
|
|
341
359
|
// Store/update in our database
|
|
342
|
-
await ctx.runMutation((
|
|
360
|
+
await ctx.runMutation((internal as any).lib.storeContact as any, {
|
|
343
361
|
email: args.contact.email,
|
|
344
362
|
firstName: args.contact.firstName,
|
|
345
363
|
lastName: args.contact.lastName,
|
|
@@ -364,7 +382,7 @@ export const addContact = za({
|
|
|
364
382
|
// Contact was created successfully
|
|
365
383
|
const data = (await response.json()) as { id?: string };
|
|
366
384
|
|
|
367
|
-
await ctx.runMutation((
|
|
385
|
+
await ctx.runMutation((internal as any).lib.storeContact as any, {
|
|
368
386
|
email: args.contact.email,
|
|
369
387
|
firstName: args.contact.firstName,
|
|
370
388
|
lastName: args.contact.lastName,
|
|
@@ -425,7 +443,7 @@ export const updateContact = za({
|
|
|
425
443
|
throw sanitizeError(response.status, errorText);
|
|
426
444
|
}
|
|
427
445
|
|
|
428
|
-
await ctx.runMutation((
|
|
446
|
+
await ctx.runMutation((internal as any).lib.storeContact as any, {
|
|
429
447
|
email: args.email,
|
|
430
448
|
firstName: args.firstName,
|
|
431
449
|
lastName: args.lastName,
|
|
@@ -470,19 +488,19 @@ export const sendTransactional = za({
|
|
|
470
488
|
if (!response.ok) {
|
|
471
489
|
const errorText = await response.text();
|
|
472
490
|
console.error(`Loops API error [${response.status}]:`, errorText);
|
|
473
|
-
await ctx.runMutation((
|
|
491
|
+
await ctx.runMutation((internal as any).lib.logEmailOperation as any, {
|
|
474
492
|
operationType: "transactional",
|
|
475
493
|
email: args.email,
|
|
476
494
|
success: false,
|
|
477
495
|
transactionalId: args.transactionalId,
|
|
478
496
|
});
|
|
479
|
-
|
|
497
|
+
|
|
480
498
|
throw sanitizeError(response.status, errorText);
|
|
481
499
|
}
|
|
482
500
|
|
|
483
501
|
const data = (await response.json()) as { messageId?: string };
|
|
484
502
|
|
|
485
|
-
await ctx.runMutation((
|
|
503
|
+
await ctx.runMutation((internal as any).lib.logEmailOperation as any, {
|
|
486
504
|
operationType: "transactional",
|
|
487
505
|
email: args.email,
|
|
488
506
|
success: true,
|
|
@@ -510,7 +528,7 @@ export const sendEvent = za({
|
|
|
510
528
|
returns: z.object({
|
|
511
529
|
success: z.boolean(),
|
|
512
530
|
}),
|
|
513
|
-
handler: async (
|
|
531
|
+
handler: async (_ctx, args) => {
|
|
514
532
|
const response = await fetch(`${LOOPS_API_BASE_URL}/events/send`, {
|
|
515
533
|
method: "POST",
|
|
516
534
|
headers: {
|
|
@@ -561,7 +579,7 @@ export const deleteContact = za({
|
|
|
561
579
|
throw sanitizeError(response.status, errorText);
|
|
562
580
|
}
|
|
563
581
|
|
|
564
|
-
await ctx.runMutation((
|
|
582
|
+
await ctx.runMutation((internal as any).lib.removeContact as any, {
|
|
565
583
|
email: args.email,
|
|
566
584
|
});
|
|
567
585
|
|
|
@@ -574,15 +592,15 @@ export const deleteContact = za({
|
|
|
574
592
|
* Note: Loops in Loops.so are triggered through events, not a direct API endpoint.
|
|
575
593
|
* This function uses the events endpoint to trigger the loop.
|
|
576
594
|
* The loop must be configured in the Loops dashboard to listen for events.
|
|
577
|
-
*
|
|
595
|
+
*
|
|
578
596
|
* IMPORTANT: Loops.so doesn't have a direct /loops/trigger endpoint.
|
|
579
597
|
* Loops are triggered by sending events. Make sure your loop in the dashboard
|
|
580
598
|
* is configured to trigger on an event name (e.g., "loop_trigger").
|
|
581
|
-
*
|
|
599
|
+
*
|
|
582
600
|
* If you need to trigger a specific loop, you should:
|
|
583
601
|
* 1. Configure the loop in the dashboard to listen for a specific event name
|
|
584
602
|
* 2. Use sendEvent() with that event name instead
|
|
585
|
-
*
|
|
603
|
+
*
|
|
586
604
|
* This function is kept for backwards compatibility but works by sending an event.
|
|
587
605
|
*/
|
|
588
606
|
export const triggerLoop = za({
|
|
@@ -602,10 +620,10 @@ export const triggerLoop = za({
|
|
|
602
620
|
// Loops are triggered through events. We'll use the events endpoint.
|
|
603
621
|
// Default event name if not provided
|
|
604
622
|
const eventName = args.eventName || `loop_${args.loopId}`;
|
|
605
|
-
|
|
623
|
+
|
|
606
624
|
try {
|
|
607
625
|
// Send event to trigger the loop
|
|
608
|
-
await ctx.runAction((
|
|
626
|
+
await ctx.runAction((internal as any).lib.sendEvent as any, {
|
|
609
627
|
apiKey: args.apiKey,
|
|
610
628
|
email: args.email,
|
|
611
629
|
eventName,
|
|
@@ -616,7 +634,7 @@ export const triggerLoop = za({
|
|
|
616
634
|
});
|
|
617
635
|
|
|
618
636
|
// Log as loop operation
|
|
619
|
-
await ctx.runMutation((
|
|
637
|
+
await ctx.runMutation((internal as any).lib.logEmailOperation as any, {
|
|
620
638
|
operationType: "loop",
|
|
621
639
|
email: args.email,
|
|
622
640
|
success: true,
|
|
@@ -626,19 +644,22 @@ export const triggerLoop = za({
|
|
|
626
644
|
|
|
627
645
|
return {
|
|
628
646
|
success: true,
|
|
629
|
-
warning:
|
|
647
|
+
warning:
|
|
648
|
+
"Loops are triggered via events. Ensure your loop is configured to listen for this event.",
|
|
630
649
|
};
|
|
631
650
|
} catch (error) {
|
|
632
651
|
// Log failed loop operation
|
|
633
|
-
await ctx.runMutation((
|
|
652
|
+
await ctx.runMutation((internal as any).lib.logEmailOperation as any, {
|
|
634
653
|
operationType: "loop",
|
|
635
654
|
email: args.email,
|
|
636
655
|
success: false,
|
|
637
656
|
loopId: args.loopId,
|
|
638
657
|
eventName,
|
|
639
|
-
metadata: {
|
|
658
|
+
metadata: {
|
|
659
|
+
error: error instanceof Error ? error.message : String(error),
|
|
660
|
+
},
|
|
640
661
|
});
|
|
641
|
-
|
|
662
|
+
|
|
642
663
|
throw error;
|
|
643
664
|
}
|
|
644
665
|
},
|
|
@@ -670,7 +691,7 @@ export const findContact = za({
|
|
|
670
691
|
})
|
|
671
692
|
.optional(),
|
|
672
693
|
}),
|
|
673
|
-
handler: async (
|
|
694
|
+
handler: async (_ctx, args) => {
|
|
674
695
|
const response = await fetch(
|
|
675
696
|
`${LOOPS_API_BASE_URL}/contacts/find?email=${encodeURIComponent(args.email)}`,
|
|
676
697
|
{
|
|
@@ -691,7 +712,9 @@ export const findContact = za({
|
|
|
691
712
|
throw sanitizeError(response.status, errorText);
|
|
692
713
|
}
|
|
693
714
|
|
|
694
|
-
const data = (await response.json()) as
|
|
715
|
+
const data = (await response.json()) as
|
|
716
|
+
| Record<string, any>
|
|
717
|
+
| Array<Record<string, any>>;
|
|
695
718
|
|
|
696
719
|
// Handle case where Loops returns an array instead of a single object
|
|
697
720
|
let contact = Array.isArray(data) ? data[0] : data;
|
|
@@ -699,7 +722,10 @@ export const findContact = za({
|
|
|
699
722
|
// Convert null values to undefined for optional fields (Zod handles undefined but not null in optional())
|
|
700
723
|
if (contact) {
|
|
701
724
|
contact = Object.fromEntries(
|
|
702
|
-
Object.entries(contact).map(([key, value]) => [
|
|
725
|
+
Object.entries(contact).map(([key, value]) => [
|
|
726
|
+
key,
|
|
727
|
+
value === null ? undefined : value,
|
|
728
|
+
]),
|
|
703
729
|
) as Record<string, any>;
|
|
704
730
|
}
|
|
705
731
|
|
|
@@ -724,35 +750,43 @@ export const batchCreateContacts = za({
|
|
|
724
750
|
success: z.boolean(),
|
|
725
751
|
created: z.number().optional(),
|
|
726
752
|
failed: z.number().optional(),
|
|
727
|
-
results: z
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
753
|
+
results: z
|
|
754
|
+
.array(
|
|
755
|
+
z.object({
|
|
756
|
+
email: z.string(),
|
|
757
|
+
success: z.boolean(),
|
|
758
|
+
error: z.string().optional(),
|
|
759
|
+
}),
|
|
760
|
+
)
|
|
761
|
+
.optional(),
|
|
732
762
|
}),
|
|
733
763
|
handler: async (ctx, args) => {
|
|
734
764
|
let created = 0;
|
|
735
765
|
let failed = 0;
|
|
736
|
-
const results: Array<{ email: string; success: boolean; error?: string }> =
|
|
766
|
+
const results: Array<{ email: string; success: boolean; error?: string }> =
|
|
767
|
+
[];
|
|
737
768
|
|
|
738
769
|
// Create contacts one by one since Loops.so doesn't have a batch endpoint
|
|
739
770
|
for (const contact of args.contacts) {
|
|
740
771
|
try {
|
|
741
772
|
// Use the addContact function which handles create/update logic
|
|
742
|
-
const result = await ctx.runAction(
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
773
|
+
const result = await ctx.runAction(
|
|
774
|
+
(internal as any).lib.addContact as any,
|
|
775
|
+
{
|
|
776
|
+
apiKey: args.apiKey,
|
|
777
|
+
contact,
|
|
778
|
+
},
|
|
779
|
+
);
|
|
746
780
|
|
|
747
781
|
if (result.success) {
|
|
748
782
|
created++;
|
|
749
783
|
results.push({ email: contact.email, success: true });
|
|
750
784
|
} else {
|
|
751
785
|
failed++;
|
|
752
|
-
results.push({
|
|
753
|
-
email: contact.email,
|
|
754
|
-
success: false,
|
|
755
|
-
error: "Unknown error"
|
|
786
|
+
results.push({
|
|
787
|
+
email: contact.email,
|
|
788
|
+
success: false,
|
|
789
|
+
error: "Unknown error",
|
|
756
790
|
});
|
|
757
791
|
}
|
|
758
792
|
} catch (error) {
|
|
@@ -802,7 +836,7 @@ export const unsubscribeContact = za({
|
|
|
802
836
|
throw sanitizeError(response.status, errorText);
|
|
803
837
|
}
|
|
804
838
|
|
|
805
|
-
await ctx.runMutation((
|
|
839
|
+
await ctx.runMutation((internal as any).lib.storeContact as any, {
|
|
806
840
|
email: args.email,
|
|
807
841
|
subscribed: false,
|
|
808
842
|
});
|
|
@@ -839,7 +873,7 @@ export const resubscribeContact = za({
|
|
|
839
873
|
throw sanitizeError(response.status, errorText);
|
|
840
874
|
}
|
|
841
875
|
|
|
842
|
-
await ctx.runMutation((
|
|
876
|
+
await ctx.runMutation((internal as any).lib.storeContact as any, {
|
|
843
877
|
email: args.email,
|
|
844
878
|
subscribed: true,
|
|
845
879
|
});
|
|
@@ -858,15 +892,17 @@ export const detectRecipientSpam = zq({
|
|
|
858
892
|
maxEmailsPerRecipient: z.number().default(10),
|
|
859
893
|
}),
|
|
860
894
|
returns: z.array(
|
|
861
|
-
z
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
895
|
+
z
|
|
896
|
+
.object({
|
|
897
|
+
email: z.string(),
|
|
898
|
+
count: z.number(),
|
|
899
|
+
timeWindowMs: z.number(),
|
|
900
|
+
})
|
|
901
|
+
.catchall(z.any()),
|
|
866
902
|
),
|
|
867
903
|
handler: async (ctx, args) => {
|
|
868
904
|
const cutoffTime = Date.now() - args.timeWindowMs;
|
|
869
|
-
|
|
905
|
+
|
|
870
906
|
const operations = await ctx.db
|
|
871
907
|
.query("emailOperations")
|
|
872
908
|
.withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
|
|
@@ -879,7 +915,11 @@ export const detectRecipientSpam = zq({
|
|
|
879
915
|
}
|
|
880
916
|
}
|
|
881
917
|
|
|
882
|
-
const suspicious: Array<{
|
|
918
|
+
const suspicious: Array<{
|
|
919
|
+
email: string;
|
|
920
|
+
count: number;
|
|
921
|
+
timeWindowMs: number;
|
|
922
|
+
}> = [];
|
|
883
923
|
for (const [email, count] of emailCounts.entries()) {
|
|
884
924
|
if (count > args.maxEmailsPerRecipient) {
|
|
885
925
|
suspicious.push({
|
|
@@ -912,7 +952,7 @@ export const detectActorSpam = zq({
|
|
|
912
952
|
),
|
|
913
953
|
handler: async (ctx, args) => {
|
|
914
954
|
const cutoffTime = Date.now() - args.timeWindowMs;
|
|
915
|
-
|
|
955
|
+
|
|
916
956
|
const operations = await ctx.db
|
|
917
957
|
.query("emailOperations")
|
|
918
958
|
.withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
|
|
@@ -925,7 +965,11 @@ export const detectActorSpam = zq({
|
|
|
925
965
|
}
|
|
926
966
|
}
|
|
927
967
|
|
|
928
|
-
const suspicious: Array<{
|
|
968
|
+
const suspicious: Array<{
|
|
969
|
+
actorId: string;
|
|
970
|
+
count: number;
|
|
971
|
+
timeWindowMs: number;
|
|
972
|
+
}> = [];
|
|
929
973
|
for (const [actorId, count] of actorCounts.entries()) {
|
|
930
974
|
if (count > args.maxEmailsPerActor) {
|
|
931
975
|
suspicious.push({
|
|
@@ -947,17 +991,19 @@ export const getEmailStats = zq({
|
|
|
947
991
|
args: z.object({
|
|
948
992
|
timeWindowMs: z.number().default(86400000),
|
|
949
993
|
}),
|
|
950
|
-
returns: z
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
994
|
+
returns: z
|
|
995
|
+
.object({
|
|
996
|
+
totalOperations: z.number(),
|
|
997
|
+
successfulOperations: z.number(),
|
|
998
|
+
failedOperations: z.number(),
|
|
999
|
+
operationsByType: z.record(z.string(), z.number()),
|
|
1000
|
+
uniqueRecipients: z.number(),
|
|
1001
|
+
uniqueActors: z.number(),
|
|
1002
|
+
})
|
|
1003
|
+
.catchall(z.any()),
|
|
958
1004
|
handler: async (ctx, args) => {
|
|
959
1005
|
const cutoffTime = Date.now() - args.timeWindowMs;
|
|
960
|
-
|
|
1006
|
+
|
|
961
1007
|
const operations = await ctx.db
|
|
962
1008
|
.query("emailOperations")
|
|
963
1009
|
.withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
|
|
@@ -975,11 +1021,11 @@ export const getEmailStats = zq({
|
|
|
975
1021
|
for (const op of operations) {
|
|
976
1022
|
stats.operationsByType[op.operationType] =
|
|
977
1023
|
(stats.operationsByType[op.operationType] ?? 0) + 1;
|
|
978
|
-
|
|
1024
|
+
|
|
979
1025
|
if (op.email && op.email !== "audience") {
|
|
980
1026
|
stats.uniqueRecipients.add(op.email);
|
|
981
1027
|
}
|
|
982
|
-
|
|
1028
|
+
|
|
983
1029
|
if (op.actorId) {
|
|
984
1030
|
stats.uniqueActors.add(op.actorId);
|
|
985
1031
|
}
|
|
@@ -1014,7 +1060,7 @@ export const detectRapidFirePatterns = zq({
|
|
|
1014
1060
|
),
|
|
1015
1061
|
handler: async (ctx, args) => {
|
|
1016
1062
|
const cutoffTime = Date.now() - args.timeWindowMs;
|
|
1017
|
-
|
|
1063
|
+
|
|
1018
1064
|
const operations = await ctx.db
|
|
1019
1065
|
.query("emailOperations")
|
|
1020
1066
|
.withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
|
|
@@ -1037,7 +1083,7 @@ export const detectRapidFirePatterns = zq({
|
|
|
1037
1083
|
if (!emailGroups.has(op.email)) {
|
|
1038
1084
|
emailGroups.set(op.email, []);
|
|
1039
1085
|
}
|
|
1040
|
-
emailGroups.get(op.email)
|
|
1086
|
+
emailGroups.get(op.email)?.push(op);
|
|
1041
1087
|
}
|
|
1042
1088
|
}
|
|
1043
1089
|
|
|
@@ -1045,13 +1091,13 @@ export const detectRapidFirePatterns = zq({
|
|
|
1045
1091
|
for (let i = 0; i < ops.length; i++) {
|
|
1046
1092
|
const op = ops[i];
|
|
1047
1093
|
if (!op) continue;
|
|
1048
|
-
|
|
1094
|
+
|
|
1049
1095
|
const windowStart = op.timestamp;
|
|
1050
1096
|
const windowEnd = windowStart + args.timeWindowMs;
|
|
1051
1097
|
const opsInWindow = ops.filter(
|
|
1052
1098
|
(op) => op.timestamp >= windowStart && op.timestamp <= windowEnd,
|
|
1053
1099
|
);
|
|
1054
|
-
|
|
1100
|
+
|
|
1055
1101
|
if (opsInWindow.length >= args.minEmailsInWindow) {
|
|
1056
1102
|
patterns.push({
|
|
1057
1103
|
email,
|
|
@@ -1070,7 +1116,7 @@ export const detectRapidFirePatterns = zq({
|
|
|
1070
1116
|
if (!actorGroups.has(op.actorId)) {
|
|
1071
1117
|
actorGroups.set(op.actorId, []);
|
|
1072
1118
|
}
|
|
1073
|
-
actorGroups.get(op.actorId)
|
|
1119
|
+
actorGroups.get(op.actorId)?.push(op);
|
|
1074
1120
|
}
|
|
1075
1121
|
}
|
|
1076
1122
|
|
|
@@ -1078,13 +1124,13 @@ export const detectRapidFirePatterns = zq({
|
|
|
1078
1124
|
for (let i = 0; i < ops.length; i++) {
|
|
1079
1125
|
const op = ops[i];
|
|
1080
1126
|
if (!op) continue;
|
|
1081
|
-
|
|
1127
|
+
|
|
1082
1128
|
const windowStart = op.timestamp;
|
|
1083
1129
|
const windowEnd = windowStart + args.timeWindowMs;
|
|
1084
1130
|
const opsInWindow = ops.filter(
|
|
1085
1131
|
(op) => op.timestamp >= windowStart && op.timestamp <= windowEnd,
|
|
1086
1132
|
);
|
|
1087
|
-
|
|
1133
|
+
|
|
1088
1134
|
if (opsInWindow.length >= args.minEmailsInWindow) {
|
|
1089
1135
|
patterns.push({
|
|
1090
1136
|
actorId,
|
|
@@ -1111,13 +1157,15 @@ export const checkRecipientRateLimit = zq({
|
|
|
1111
1157
|
timeWindowMs: z.number(),
|
|
1112
1158
|
maxEmails: z.number(),
|
|
1113
1159
|
}),
|
|
1114
|
-
returns: z
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1160
|
+
returns: z
|
|
1161
|
+
.object({
|
|
1162
|
+
allowed: z.boolean(),
|
|
1163
|
+
count: z.number(),
|
|
1164
|
+
limit: z.number(),
|
|
1165
|
+
timeWindowMs: z.number(),
|
|
1166
|
+
retryAfter: z.number().optional(),
|
|
1167
|
+
})
|
|
1168
|
+
.catchall(z.any()),
|
|
1121
1169
|
handler: async (ctx, args) => {
|
|
1122
1170
|
const cutoffTime = Date.now() - args.timeWindowMs;
|
|
1123
1171
|
|
|
@@ -2,12 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { zodTable } from "zodvex";
|
|
3
3
|
|
|
4
4
|
export const EmailOperations = zodTable("emailOperations", {
|
|
5
|
-
operationType: z.enum([
|
|
6
|
-
"transactional",
|
|
7
|
-
"event",
|
|
8
|
-
"campaign",
|
|
9
|
-
"loop",
|
|
10
|
-
]),
|
|
5
|
+
operationType: z.enum(["transactional", "event", "campaign", "loop"]),
|
|
11
6
|
email: z.string().email(),
|
|
12
7
|
actorId: z.string().optional(),
|
|
13
8
|
transactionalId: z.string().optional(),
|
|
@@ -19,4 +14,3 @@ export const EmailOperations = zodTable("emailOperations", {
|
|
|
19
14
|
messageId: z.string().optional(),
|
|
20
15
|
metadata: z.optional(z.record(z.string(), z.any())),
|
|
21
16
|
});
|
|
22
|
-
|