@devwithbobby/loops 0.1.1 → 0.1.3

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