@devwithbobby/loops 0.1.11 → 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/README.md +1 -24
- package/dist/client/index.d.ts +122 -52
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +15 -26
- package/dist/component/convex.config.d.ts +1 -1
- package/dist/component/convex.config.d.ts.map +1 -1
- package/dist/component/convex.config.js +0 -1
- package/dist/component/lib.d.ts +237 -29
- package/dist/component/lib.d.ts.map +1 -1
- package/dist/component/lib.js +47 -133
- 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 +56 -57
- package/src/component/convex.config.ts +0 -1
- package/src/component/lib.ts +146 -219
- 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
|
|
|
@@ -569,141 +587,20 @@ export const deleteContact = za({
|
|
|
569
587
|
},
|
|
570
588
|
});
|
|
571
589
|
|
|
572
|
-
/**
|
|
573
|
-
* Send a campaign to contacts
|
|
574
|
-
* Note: Campaigns in Loops.so are typically managed from the dashboard.
|
|
575
|
-
* This function sends transactional emails to multiple contacts as a workaround.
|
|
576
|
-
* If you need true campaign functionality, use the Loops.so dashboard or contact their support.
|
|
577
|
-
*/
|
|
578
|
-
export const sendCampaign = za({
|
|
579
|
-
args: z.object({
|
|
580
|
-
apiKey: z.string(),
|
|
581
|
-
campaignId: z.string(),
|
|
582
|
-
emails: z.array(z.string().email()).optional(),
|
|
583
|
-
transactionalId: z.string().optional(),
|
|
584
|
-
dataVariables: z.record(z.string(), z.any()).optional(),
|
|
585
|
-
audienceFilters: z
|
|
586
|
-
.object({
|
|
587
|
-
userGroup: z.string().optional(),
|
|
588
|
-
source: z.string().optional(),
|
|
589
|
-
})
|
|
590
|
-
.optional(),
|
|
591
|
-
}),
|
|
592
|
-
returns: z.object({
|
|
593
|
-
success: z.boolean(),
|
|
594
|
-
messageId: z.string().optional(),
|
|
595
|
-
sent: z.number().optional(),
|
|
596
|
-
errors: z.array(z.object({
|
|
597
|
-
email: z.string(),
|
|
598
|
-
error: z.string(),
|
|
599
|
-
})).optional(),
|
|
600
|
-
}),
|
|
601
|
-
handler: async (ctx, args) => {
|
|
602
|
-
// Loops.so doesn't have a campaigns API endpoint
|
|
603
|
-
// As a workaround, we'll send transactional emails to the specified contacts
|
|
604
|
-
|
|
605
|
-
if (!args.transactionalId) {
|
|
606
|
-
throw new Error(
|
|
607
|
-
"Campaigns require a transactionalId. " +
|
|
608
|
-
"Loops.so campaigns are managed from the dashboard. " +
|
|
609
|
-
"This function sends transactional emails to multiple contacts as a workaround. " +
|
|
610
|
-
"Please provide a transactionalId to send emails."
|
|
611
|
-
);
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
if (!args.emails || args.emails.length === 0) {
|
|
615
|
-
// If no emails provided but audienceFilters are, we need to query contacts
|
|
616
|
-
if (args.audienceFilters) {
|
|
617
|
-
// Query contacts from our database based on filters
|
|
618
|
-
const contacts = await ctx.runQuery(((internal as any).lib).countContacts as any, {
|
|
619
|
-
userGroup: args.audienceFilters.userGroup,
|
|
620
|
-
source: args.audienceFilters.source,
|
|
621
|
-
});
|
|
622
|
-
|
|
623
|
-
if (contacts === 0) {
|
|
624
|
-
return {
|
|
625
|
-
success: false,
|
|
626
|
-
sent: 0,
|
|
627
|
-
errors: [{ email: "audience", error: "No contacts found matching filters" }],
|
|
628
|
-
};
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
// Note: We can't get email list from countContacts, so this is a limitation
|
|
632
|
-
throw new Error(
|
|
633
|
-
"Campaigns with audienceFilters require emails to be specified. " +
|
|
634
|
-
"Please provide the emails array with contacts to send to."
|
|
635
|
-
);
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
throw new Error(
|
|
639
|
-
"Campaigns require either emails array or audienceFilters. " +
|
|
640
|
-
"Please provide at least one email address or use transactional emails for single contacts."
|
|
641
|
-
);
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
// Send transactional emails to each contact as a workaround for campaigns
|
|
645
|
-
let sent = 0;
|
|
646
|
-
const errors: Array<{ email: string; error: string }> = [];
|
|
647
|
-
|
|
648
|
-
for (const email of args.emails) {
|
|
649
|
-
try {
|
|
650
|
-
await ctx.runAction(((internal as any).lib).sendTransactional as any, {
|
|
651
|
-
apiKey: args.apiKey,
|
|
652
|
-
transactionalId: args.transactionalId!,
|
|
653
|
-
email,
|
|
654
|
-
dataVariables: args.dataVariables,
|
|
655
|
-
});
|
|
656
|
-
|
|
657
|
-
sent++;
|
|
658
|
-
|
|
659
|
-
// Log as campaign operation
|
|
660
|
-
await ctx.runMutation(((internal as any).lib).logEmailOperation as any, {
|
|
661
|
-
operationType: "campaign",
|
|
662
|
-
email,
|
|
663
|
-
success: true,
|
|
664
|
-
campaignId: args.campaignId,
|
|
665
|
-
transactionalId: args.transactionalId,
|
|
666
|
-
});
|
|
667
|
-
} catch (error) {
|
|
668
|
-
errors.push({
|
|
669
|
-
email,
|
|
670
|
-
error: error instanceof Error ? error.message : String(error),
|
|
671
|
-
});
|
|
672
|
-
|
|
673
|
-
// Log failed campaign operation
|
|
674
|
-
await ctx.runMutation(((internal as any).lib).logEmailOperation as any, {
|
|
675
|
-
operationType: "campaign",
|
|
676
|
-
email,
|
|
677
|
-
success: false,
|
|
678
|
-
campaignId: args.campaignId,
|
|
679
|
-
transactionalId: args.transactionalId,
|
|
680
|
-
metadata: { error: error instanceof Error ? error.message : String(error) },
|
|
681
|
-
});
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
return {
|
|
686
|
-
success: sent > 0,
|
|
687
|
-
sent,
|
|
688
|
-
errors: errors.length > 0 ? errors : undefined,
|
|
689
|
-
};
|
|
690
|
-
},
|
|
691
|
-
});
|
|
692
|
-
|
|
693
590
|
/**
|
|
694
591
|
* Trigger a loop for a contact
|
|
695
592
|
* Note: Loops in Loops.so are triggered through events, not a direct API endpoint.
|
|
696
593
|
* This function uses the events endpoint to trigger the loop.
|
|
697
594
|
* The loop must be configured in the Loops dashboard to listen for events.
|
|
698
|
-
*
|
|
595
|
+
*
|
|
699
596
|
* IMPORTANT: Loops.so doesn't have a direct /loops/trigger endpoint.
|
|
700
597
|
* Loops are triggered by sending events. Make sure your loop in the dashboard
|
|
701
598
|
* is configured to trigger on an event name (e.g., "loop_trigger").
|
|
702
|
-
*
|
|
599
|
+
*
|
|
703
600
|
* If you need to trigger a specific loop, you should:
|
|
704
601
|
* 1. Configure the loop in the dashboard to listen for a specific event name
|
|
705
602
|
* 2. Use sendEvent() with that event name instead
|
|
706
|
-
*
|
|
603
|
+
*
|
|
707
604
|
* This function is kept for backwards compatibility but works by sending an event.
|
|
708
605
|
*/
|
|
709
606
|
export const triggerLoop = za({
|
|
@@ -723,10 +620,10 @@ export const triggerLoop = za({
|
|
|
723
620
|
// Loops are triggered through events. We'll use the events endpoint.
|
|
724
621
|
// Default event name if not provided
|
|
725
622
|
const eventName = args.eventName || `loop_${args.loopId}`;
|
|
726
|
-
|
|
623
|
+
|
|
727
624
|
try {
|
|
728
625
|
// Send event to trigger the loop
|
|
729
|
-
await ctx.runAction((
|
|
626
|
+
await ctx.runAction((internal as any).lib.sendEvent as any, {
|
|
730
627
|
apiKey: args.apiKey,
|
|
731
628
|
email: args.email,
|
|
732
629
|
eventName,
|
|
@@ -737,7 +634,7 @@ export const triggerLoop = za({
|
|
|
737
634
|
});
|
|
738
635
|
|
|
739
636
|
// Log as loop operation
|
|
740
|
-
await ctx.runMutation((
|
|
637
|
+
await ctx.runMutation((internal as any).lib.logEmailOperation as any, {
|
|
741
638
|
operationType: "loop",
|
|
742
639
|
email: args.email,
|
|
743
640
|
success: true,
|
|
@@ -747,19 +644,22 @@ export const triggerLoop = za({
|
|
|
747
644
|
|
|
748
645
|
return {
|
|
749
646
|
success: true,
|
|
750
|
-
warning:
|
|
647
|
+
warning:
|
|
648
|
+
"Loops are triggered via events. Ensure your loop is configured to listen for this event.",
|
|
751
649
|
};
|
|
752
650
|
} catch (error) {
|
|
753
651
|
// Log failed loop operation
|
|
754
|
-
await ctx.runMutation((
|
|
652
|
+
await ctx.runMutation((internal as any).lib.logEmailOperation as any, {
|
|
755
653
|
operationType: "loop",
|
|
756
654
|
email: args.email,
|
|
757
655
|
success: false,
|
|
758
656
|
loopId: args.loopId,
|
|
759
657
|
eventName,
|
|
760
|
-
metadata: {
|
|
658
|
+
metadata: {
|
|
659
|
+
error: error instanceof Error ? error.message : String(error),
|
|
660
|
+
},
|
|
761
661
|
});
|
|
762
|
-
|
|
662
|
+
|
|
763
663
|
throw error;
|
|
764
664
|
}
|
|
765
665
|
},
|
|
@@ -791,7 +691,7 @@ export const findContact = za({
|
|
|
791
691
|
})
|
|
792
692
|
.optional(),
|
|
793
693
|
}),
|
|
794
|
-
handler: async (
|
|
694
|
+
handler: async (_ctx, args) => {
|
|
795
695
|
const response = await fetch(
|
|
796
696
|
`${LOOPS_API_BASE_URL}/contacts/find?email=${encodeURIComponent(args.email)}`,
|
|
797
697
|
{
|
|
@@ -812,7 +712,9 @@ export const findContact = za({
|
|
|
812
712
|
throw sanitizeError(response.status, errorText);
|
|
813
713
|
}
|
|
814
714
|
|
|
815
|
-
const data = (await response.json()) as
|
|
715
|
+
const data = (await response.json()) as
|
|
716
|
+
| Record<string, any>
|
|
717
|
+
| Array<Record<string, any>>;
|
|
816
718
|
|
|
817
719
|
// Handle case where Loops returns an array instead of a single object
|
|
818
720
|
let contact = Array.isArray(data) ? data[0] : data;
|
|
@@ -820,7 +722,10 @@ export const findContact = za({
|
|
|
820
722
|
// Convert null values to undefined for optional fields (Zod handles undefined but not null in optional())
|
|
821
723
|
if (contact) {
|
|
822
724
|
contact = Object.fromEntries(
|
|
823
|
-
Object.entries(contact).map(([key, value]) => [
|
|
725
|
+
Object.entries(contact).map(([key, value]) => [
|
|
726
|
+
key,
|
|
727
|
+
value === null ? undefined : value,
|
|
728
|
+
]),
|
|
824
729
|
) as Record<string, any>;
|
|
825
730
|
}
|
|
826
731
|
|
|
@@ -845,35 +750,43 @@ export const batchCreateContacts = za({
|
|
|
845
750
|
success: z.boolean(),
|
|
846
751
|
created: z.number().optional(),
|
|
847
752
|
failed: z.number().optional(),
|
|
848
|
-
results: z
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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(),
|
|
853
762
|
}),
|
|
854
763
|
handler: async (ctx, args) => {
|
|
855
764
|
let created = 0;
|
|
856
765
|
let failed = 0;
|
|
857
|
-
const results: Array<{ email: string; success: boolean; error?: string }> =
|
|
766
|
+
const results: Array<{ email: string; success: boolean; error?: string }> =
|
|
767
|
+
[];
|
|
858
768
|
|
|
859
769
|
// Create contacts one by one since Loops.so doesn't have a batch endpoint
|
|
860
770
|
for (const contact of args.contacts) {
|
|
861
771
|
try {
|
|
862
772
|
// Use the addContact function which handles create/update logic
|
|
863
|
-
const result = await ctx.runAction(
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
773
|
+
const result = await ctx.runAction(
|
|
774
|
+
(internal as any).lib.addContact as any,
|
|
775
|
+
{
|
|
776
|
+
apiKey: args.apiKey,
|
|
777
|
+
contact,
|
|
778
|
+
},
|
|
779
|
+
);
|
|
867
780
|
|
|
868
781
|
if (result.success) {
|
|
869
782
|
created++;
|
|
870
783
|
results.push({ email: contact.email, success: true });
|
|
871
784
|
} else {
|
|
872
785
|
failed++;
|
|
873
|
-
results.push({
|
|
874
|
-
email: contact.email,
|
|
875
|
-
success: false,
|
|
876
|
-
error: "Unknown error"
|
|
786
|
+
results.push({
|
|
787
|
+
email: contact.email,
|
|
788
|
+
success: false,
|
|
789
|
+
error: "Unknown error",
|
|
877
790
|
});
|
|
878
791
|
}
|
|
879
792
|
} catch (error) {
|
|
@@ -923,7 +836,7 @@ export const unsubscribeContact = za({
|
|
|
923
836
|
throw sanitizeError(response.status, errorText);
|
|
924
837
|
}
|
|
925
838
|
|
|
926
|
-
await ctx.runMutation((
|
|
839
|
+
await ctx.runMutation((internal as any).lib.storeContact as any, {
|
|
927
840
|
email: args.email,
|
|
928
841
|
subscribed: false,
|
|
929
842
|
});
|
|
@@ -960,7 +873,7 @@ export const resubscribeContact = za({
|
|
|
960
873
|
throw sanitizeError(response.status, errorText);
|
|
961
874
|
}
|
|
962
875
|
|
|
963
|
-
await ctx.runMutation((
|
|
876
|
+
await ctx.runMutation((internal as any).lib.storeContact as any, {
|
|
964
877
|
email: args.email,
|
|
965
878
|
subscribed: true,
|
|
966
879
|
});
|
|
@@ -979,15 +892,17 @@ export const detectRecipientSpam = zq({
|
|
|
979
892
|
maxEmailsPerRecipient: z.number().default(10),
|
|
980
893
|
}),
|
|
981
894
|
returns: z.array(
|
|
982
|
-
z
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
895
|
+
z
|
|
896
|
+
.object({
|
|
897
|
+
email: z.string(),
|
|
898
|
+
count: z.number(),
|
|
899
|
+
timeWindowMs: z.number(),
|
|
900
|
+
})
|
|
901
|
+
.catchall(z.any()),
|
|
987
902
|
),
|
|
988
903
|
handler: async (ctx, args) => {
|
|
989
904
|
const cutoffTime = Date.now() - args.timeWindowMs;
|
|
990
|
-
|
|
905
|
+
|
|
991
906
|
const operations = await ctx.db
|
|
992
907
|
.query("emailOperations")
|
|
993
908
|
.withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
|
|
@@ -1000,7 +915,11 @@ export const detectRecipientSpam = zq({
|
|
|
1000
915
|
}
|
|
1001
916
|
}
|
|
1002
917
|
|
|
1003
|
-
const suspicious: Array<{
|
|
918
|
+
const suspicious: Array<{
|
|
919
|
+
email: string;
|
|
920
|
+
count: number;
|
|
921
|
+
timeWindowMs: number;
|
|
922
|
+
}> = [];
|
|
1004
923
|
for (const [email, count] of emailCounts.entries()) {
|
|
1005
924
|
if (count > args.maxEmailsPerRecipient) {
|
|
1006
925
|
suspicious.push({
|
|
@@ -1033,7 +952,7 @@ export const detectActorSpam = zq({
|
|
|
1033
952
|
),
|
|
1034
953
|
handler: async (ctx, args) => {
|
|
1035
954
|
const cutoffTime = Date.now() - args.timeWindowMs;
|
|
1036
|
-
|
|
955
|
+
|
|
1037
956
|
const operations = await ctx.db
|
|
1038
957
|
.query("emailOperations")
|
|
1039
958
|
.withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
|
|
@@ -1046,7 +965,11 @@ export const detectActorSpam = zq({
|
|
|
1046
965
|
}
|
|
1047
966
|
}
|
|
1048
967
|
|
|
1049
|
-
const suspicious: Array<{
|
|
968
|
+
const suspicious: Array<{
|
|
969
|
+
actorId: string;
|
|
970
|
+
count: number;
|
|
971
|
+
timeWindowMs: number;
|
|
972
|
+
}> = [];
|
|
1050
973
|
for (const [actorId, count] of actorCounts.entries()) {
|
|
1051
974
|
if (count > args.maxEmailsPerActor) {
|
|
1052
975
|
suspicious.push({
|
|
@@ -1068,17 +991,19 @@ export const getEmailStats = zq({
|
|
|
1068
991
|
args: z.object({
|
|
1069
992
|
timeWindowMs: z.number().default(86400000),
|
|
1070
993
|
}),
|
|
1071
|
-
returns: z
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
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()),
|
|
1079
1004
|
handler: async (ctx, args) => {
|
|
1080
1005
|
const cutoffTime = Date.now() - args.timeWindowMs;
|
|
1081
|
-
|
|
1006
|
+
|
|
1082
1007
|
const operations = await ctx.db
|
|
1083
1008
|
.query("emailOperations")
|
|
1084
1009
|
.withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
|
|
@@ -1096,11 +1021,11 @@ export const getEmailStats = zq({
|
|
|
1096
1021
|
for (const op of operations) {
|
|
1097
1022
|
stats.operationsByType[op.operationType] =
|
|
1098
1023
|
(stats.operationsByType[op.operationType] ?? 0) + 1;
|
|
1099
|
-
|
|
1024
|
+
|
|
1100
1025
|
if (op.email && op.email !== "audience") {
|
|
1101
1026
|
stats.uniqueRecipients.add(op.email);
|
|
1102
1027
|
}
|
|
1103
|
-
|
|
1028
|
+
|
|
1104
1029
|
if (op.actorId) {
|
|
1105
1030
|
stats.uniqueActors.add(op.actorId);
|
|
1106
1031
|
}
|
|
@@ -1135,7 +1060,7 @@ export const detectRapidFirePatterns = zq({
|
|
|
1135
1060
|
),
|
|
1136
1061
|
handler: async (ctx, args) => {
|
|
1137
1062
|
const cutoffTime = Date.now() - args.timeWindowMs;
|
|
1138
|
-
|
|
1063
|
+
|
|
1139
1064
|
const operations = await ctx.db
|
|
1140
1065
|
.query("emailOperations")
|
|
1141
1066
|
.withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
|
|
@@ -1158,7 +1083,7 @@ export const detectRapidFirePatterns = zq({
|
|
|
1158
1083
|
if (!emailGroups.has(op.email)) {
|
|
1159
1084
|
emailGroups.set(op.email, []);
|
|
1160
1085
|
}
|
|
1161
|
-
emailGroups.get(op.email)
|
|
1086
|
+
emailGroups.get(op.email)?.push(op);
|
|
1162
1087
|
}
|
|
1163
1088
|
}
|
|
1164
1089
|
|
|
@@ -1166,13 +1091,13 @@ export const detectRapidFirePatterns = zq({
|
|
|
1166
1091
|
for (let i = 0; i < ops.length; i++) {
|
|
1167
1092
|
const op = ops[i];
|
|
1168
1093
|
if (!op) continue;
|
|
1169
|
-
|
|
1094
|
+
|
|
1170
1095
|
const windowStart = op.timestamp;
|
|
1171
1096
|
const windowEnd = windowStart + args.timeWindowMs;
|
|
1172
1097
|
const opsInWindow = ops.filter(
|
|
1173
1098
|
(op) => op.timestamp >= windowStart && op.timestamp <= windowEnd,
|
|
1174
1099
|
);
|
|
1175
|
-
|
|
1100
|
+
|
|
1176
1101
|
if (opsInWindow.length >= args.minEmailsInWindow) {
|
|
1177
1102
|
patterns.push({
|
|
1178
1103
|
email,
|
|
@@ -1191,7 +1116,7 @@ export const detectRapidFirePatterns = zq({
|
|
|
1191
1116
|
if (!actorGroups.has(op.actorId)) {
|
|
1192
1117
|
actorGroups.set(op.actorId, []);
|
|
1193
1118
|
}
|
|
1194
|
-
actorGroups.get(op.actorId)
|
|
1119
|
+
actorGroups.get(op.actorId)?.push(op);
|
|
1195
1120
|
}
|
|
1196
1121
|
}
|
|
1197
1122
|
|
|
@@ -1199,13 +1124,13 @@ export const detectRapidFirePatterns = zq({
|
|
|
1199
1124
|
for (let i = 0; i < ops.length; i++) {
|
|
1200
1125
|
const op = ops[i];
|
|
1201
1126
|
if (!op) continue;
|
|
1202
|
-
|
|
1127
|
+
|
|
1203
1128
|
const windowStart = op.timestamp;
|
|
1204
1129
|
const windowEnd = windowStart + args.timeWindowMs;
|
|
1205
1130
|
const opsInWindow = ops.filter(
|
|
1206
1131
|
(op) => op.timestamp >= windowStart && op.timestamp <= windowEnd,
|
|
1207
1132
|
);
|
|
1208
|
-
|
|
1133
|
+
|
|
1209
1134
|
if (opsInWindow.length >= args.minEmailsInWindow) {
|
|
1210
1135
|
patterns.push({
|
|
1211
1136
|
actorId,
|
|
@@ -1232,13 +1157,15 @@ export const checkRecipientRateLimit = zq({
|
|
|
1232
1157
|
timeWindowMs: z.number(),
|
|
1233
1158
|
maxEmails: z.number(),
|
|
1234
1159
|
}),
|
|
1235
|
-
returns: z
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
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()),
|
|
1242
1169
|
handler: async (ctx, args) => {
|
|
1243
1170
|
const cutoffTime = Date.now() - args.timeWindowMs;
|
|
1244
1171
|
|