@devwithbobby/loops 0.1.0 → 0.1.2
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 +343 -375
- package/dist/client/index.d.ts +186 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +396 -0
- package/dist/client/types.d.ts +24 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/types.js +0 -0
- package/dist/component/convex.config.d.ts +3 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +25 -0
- package/dist/component/lib.d.ts +103 -0
- package/dist/component/lib.d.ts.map +1 -0
- package/dist/component/lib.js +1000 -0
- package/dist/component/schema.d.ts +3 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +16 -0
- package/dist/component/tables/contacts.d.ts +2 -0
- package/dist/component/tables/contacts.d.ts.map +1 -0
- package/dist/component/tables/contacts.js +14 -0
- package/dist/component/tables/emailOperations.d.ts +2 -0
- package/dist/component/tables/emailOperations.d.ts.map +1 -0
- package/dist/component/tables/emailOperations.js +20 -0
- package/dist/component/validators.d.ts +18 -0
- package/dist/component/validators.d.ts.map +1 -0
- package/dist/component/validators.js +34 -0
- package/dist/utils.d.ts +4 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +5 -0
- package/package.json +11 -5
- package/.changeset/README.md +0 -8
- package/.changeset/config.json +0 -14
- package/.config/commitlint.config.ts +0 -11
- package/.config/lefthook.yml +0 -11
- package/.github/workflows/release.yml +0 -52
- package/.github/workflows/test-and-lint.yml +0 -39
- package/biome.json +0 -45
- package/bun.lock +0 -1166
- package/bunfig.toml +0 -7
- package/convex.json +0 -3
- package/example/CLAUDE.md +0 -106
- package/example/README.md +0 -21
- package/example/bun-env.d.ts +0 -17
- package/example/convex/_generated/api.d.ts +0 -53
- package/example/convex/_generated/api.js +0 -23
- package/example/convex/_generated/dataModel.d.ts +0 -60
- package/example/convex/_generated/server.d.ts +0 -149
- package/example/convex/_generated/server.js +0 -90
- package/example/convex/convex.config.ts +0 -7
- package/example/convex/example.ts +0 -76
- package/example/convex/schema.ts +0 -3
- package/example/convex/tsconfig.json +0 -34
- package/example/src/App.tsx +0 -185
- package/example/src/frontend.tsx +0 -39
- package/example/src/index.css +0 -15
- package/example/src/index.html +0 -12
- package/example/src/index.tsx +0 -19
- package/example/tsconfig.json +0 -28
- package/prds/CHANGELOG.md +0 -38
- package/prds/CLAUDE.md +0 -408
- package/prds/CONTRIBUTING.md +0 -274
- package/prds/ENV_SETUP.md +0 -222
- package/prds/MONITORING.md +0 -301
- package/prds/RATE_LIMITING.md +0 -412
- package/prds/SECURITY.md +0 -246
- package/renovate.json +0 -32
- package/test/client/_generated/_ignore.ts +0 -1
- package/test/client/index.test.ts +0 -65
- package/test/client/setup.test.ts +0 -54
- package/test/component/lib.test.ts +0 -225
- package/test/component/setup.test.ts +0 -21
- package/tsconfig.build.json +0 -20
- 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
|
+
});
|