@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.
- package/dist/client/index.d.ts +305 -44
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +21 -32
- 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 +1 -1
- package/dist/component/helpers.d.ts +7 -0
- package/dist/component/helpers.d.ts.map +1 -0
- package/dist/component/helpers.js +30 -0
- package/dist/component/http.d.ts +3 -0
- package/dist/component/http.d.ts.map +1 -0
- package/dist/component/http.js +268 -0
- package/dist/component/lib.d.ts +237 -22
- package/dist/component/lib.d.ts.map +1 -1
- package/dist/component/lib.js +91 -143
- 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/types.d.ts +97 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- 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 +40 -52
- package/src/component/_generated/api.d.ts +3 -11
- package/src/component/convex.config.ts +7 -2
- package/src/component/helpers.ts +44 -0
- package/src/component/http.ts +304 -0
- package/src/component/lib.ts +189 -204
- 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/types.ts +168 -0
- package/src/client/types.ts +0 -64
package/src/component/lib.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
268
|
+
const createResponse = await loopsFetch(args.apiKey, "/contacts/create", {
|
|
281
269
|
method: "POST",
|
|
282
|
-
|
|
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 (!
|
|
290
|
-
const errorText = await
|
|
291
|
-
|
|
292
|
-
if (
|
|
293
|
-
console.log(
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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(
|
|
289
|
+
console.error(
|
|
290
|
+
`Failed to find existing contact [${findResponse.status}]:`,
|
|
291
|
+
findErrorText,
|
|
292
|
+
);
|
|
309
293
|
}
|
|
310
294
|
|
|
311
|
-
const updateResponse = await
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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(
|
|
331
|
-
|
|
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(
|
|
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
|
-
|
|
360
|
-
|
|
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
|
|
351
|
+
const data = (await createResponse.json()) as { id?: string };
|
|
366
352
|
|
|
367
|
-
await ctx.runMutation(
|
|
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
|
|
390
|
+
const response = await loopsFetch(args.apiKey, "/contacts/update", {
|
|
405
391
|
method: "PUT",
|
|
406
|
-
|
|
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
|
|
407
|
+
throw sanitizeLoopsError(response.status, errorText);
|
|
426
408
|
}
|
|
427
409
|
|
|
428
|
-
await ctx.runMutation(
|
|
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
|
|
439
|
+
const response = await loopsFetch(args.apiKey, "/transactional", {
|
|
458
440
|
method: "POST",
|
|
459
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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 (
|
|
514
|
-
const response = await
|
|
491
|
+
handler: async (_ctx, args) => {
|
|
492
|
+
const response = await loopsFetch(args.apiKey, "/events/send", {
|
|
515
493
|
method: "POST",
|
|
516
|
-
|
|
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
|
|
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
|
|
523
|
+
const response = await loopsFetch(args.apiKey, "/contacts/delete", {
|
|
550
524
|
method: "POST",
|
|
551
|
-
|
|
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
|
|
531
|
+
throw sanitizeLoopsError(response.status, errorText);
|
|
562
532
|
}
|
|
563
533
|
|
|
564
|
-
await ctx.runMutation(
|
|
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(
|
|
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(
|
|
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:
|
|
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(
|
|
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: {
|
|
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 (
|
|
674
|
-
const response = await
|
|
675
|
-
|
|
676
|
-
{
|
|
677
|
-
|
|
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
|
|
659
|
+
throw sanitizeLoopsError(response.status, errorText);
|
|
692
660
|
}
|
|
693
661
|
|
|
694
|
-
|
|
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]) => [
|
|
703
|
-
|
|
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
|
|
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
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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(
|
|
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
|
|
769
|
+
const response = await loopsFetch(args.apiKey, "/contacts/unsubscribe", {
|
|
791
770
|
method: "POST",
|
|
792
|
-
|
|
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
|
|
777
|
+
throw sanitizeLoopsError(response.status, errorText);
|
|
803
778
|
}
|
|
804
779
|
|
|
805
|
-
await ctx.runMutation(
|
|
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
|
|
802
|
+
const response = await loopsFetch(args.apiKey, "/contacts/resubscribe", {
|
|
828
803
|
method: "POST",
|
|
829
|
-
|
|
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
|
|
810
|
+
throw sanitizeLoopsError(response.status, errorText);
|
|
840
811
|
}
|
|
841
812
|
|
|
842
|
-
await ctx.runMutation(
|
|
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
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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<{
|
|
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<{
|
|
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
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
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)
|
|
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)
|
|
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
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
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
|
|