@niama/loops 0.2.0
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 +506 -0
- package/dist/client/index.d.ts +510 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +464 -0
- package/dist/component/_generated/api.d.ts +232 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +30 -0
- package/dist/component/_generated/component.d.ts +245 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +9 -0
- package/dist/component/_generated/dataModel.d.ts +46 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +10 -0
- package/dist/component/_generated/server.d.ts +121 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +77 -0
- package/dist/component/actions.d.ts +159 -0
- package/dist/component/actions.d.ts.map +1 -0
- package/dist/component/actions.js +468 -0
- package/dist/component/aggregates.d.ts +42 -0
- package/dist/component/aggregates.d.ts.map +1 -0
- package/dist/component/aggregates.js +54 -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 +5 -0
- package/dist/component/helpers.d.ts +16 -0
- package/dist/component/helpers.d.ts.map +1 -0
- package/dist/component/helpers.js +98 -0
- package/dist/component/http.d.ts +3 -0
- package/dist/component/http.d.ts.map +1 -0
- package/dist/component/http.js +208 -0
- package/dist/component/mutations.d.ts +55 -0
- package/dist/component/mutations.d.ts.map +1 -0
- package/dist/component/mutations.js +167 -0
- package/dist/component/queries.d.ts +171 -0
- package/dist/component/queries.d.ts.map +1 -0
- package/dist/component/queries.js +516 -0
- package/dist/component/schema.d.ts +63 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +16 -0
- package/dist/component/tables/contacts.d.ts +16 -0
- package/dist/component/tables/contacts.d.ts.map +1 -0
- package/dist/component/tables/contacts.js +16 -0
- package/dist/component/tables/emailOperations.d.ts +17 -0
- package/dist/component/tables/emailOperations.d.ts.map +1 -0
- package/dist/component/tables/emailOperations.js +17 -0
- package/dist/component/validators.d.ts +338 -0
- package/dist/component/validators.d.ts.map +1 -0
- package/dist/component/validators.js +167 -0
- package/dist/test.d.ts +78 -0
- package/dist/test.d.ts.map +1 -0
- package/dist/test.js +16 -0
- package/dist/types.d.ts +39 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +0 -0
- package/package.json +112 -0
- package/src/client/index.ts +618 -0
- package/src/component/_generated/api.ts +253 -0
- package/src/component/_generated/component.ts +291 -0
- package/src/component/_generated/dataModel.ts +60 -0
- package/src/component/_generated/server.ts +161 -0
- package/src/component/actions.ts +556 -0
- package/src/component/aggregates.ts +89 -0
- package/src/component/convex.config.ts +8 -0
- package/src/component/helpers.ts +130 -0
- package/src/component/http.ts +236 -0
- package/src/component/mutations.ts +192 -0
- package/src/component/queries.ts +604 -0
- package/src/component/schema.ts +17 -0
- package/src/component/tables/contacts.ts +17 -0
- package/src/component/tables/emailOperations.ts +23 -0
- package/src/component/validators.ts +197 -0
- package/src/test.ts +27 -0
- package/src/types.ts +62 -0
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
import { actionGeneric, mutationGeneric, queryGeneric } from "convex/server";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
import type { ComponentApi } from "../component/_generated/component.js";
|
|
4
|
+
import type { RunActionCtx, RunMutationCtx, RunQueryCtx } from "../types";
|
|
5
|
+
|
|
6
|
+
export type LoopsComponent = ComponentApi;
|
|
7
|
+
|
|
8
|
+
export interface ContactData {
|
|
9
|
+
email: string;
|
|
10
|
+
firstName?: string;
|
|
11
|
+
lastName?: string;
|
|
12
|
+
userId?: string;
|
|
13
|
+
source?: string;
|
|
14
|
+
subscribed?: boolean;
|
|
15
|
+
userGroup?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TransactionalEmailOptions {
|
|
19
|
+
transactionalId: string;
|
|
20
|
+
email: string;
|
|
21
|
+
dataVariables?: Record<string, unknown>;
|
|
22
|
+
idempotencyKey?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface EventOptions {
|
|
26
|
+
email: string;
|
|
27
|
+
eventName: string;
|
|
28
|
+
eventProperties?: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class Loops {
|
|
32
|
+
public readonly options?: {
|
|
33
|
+
apiKey?: string;
|
|
34
|
+
};
|
|
35
|
+
private readonly actions: NonNullable<LoopsComponent["actions"]>;
|
|
36
|
+
private readonly queries: NonNullable<LoopsComponent["queries"]>;
|
|
37
|
+
private readonly mutations: NonNullable<LoopsComponent["mutations"]>;
|
|
38
|
+
private _apiKey?: string;
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
component: LoopsComponent,
|
|
42
|
+
options?: {
|
|
43
|
+
apiKey?: string;
|
|
44
|
+
},
|
|
45
|
+
) {
|
|
46
|
+
if (!component) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
"Loops component reference is required. " +
|
|
49
|
+
"Make sure the component is mounted in your convex.config.ts and use: " +
|
|
50
|
+
"new Loops(components.loops)",
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!component.actions || !component.queries || !component.mutations) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
"Invalid component reference. " +
|
|
57
|
+
"The component may not be properly mounted. " +
|
|
58
|
+
"Ensure the component is correctly mounted in convex.config.ts: " +
|
|
59
|
+
"app.use(loops);",
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this.actions = component.actions;
|
|
64
|
+
this.queries = component.queries;
|
|
65
|
+
this.mutations = component.mutations;
|
|
66
|
+
this.options = options;
|
|
67
|
+
this._apiKey = options?.apiKey;
|
|
68
|
+
|
|
69
|
+
if (options?.apiKey) {
|
|
70
|
+
console.warn(
|
|
71
|
+
"API key passed directly via options. " +
|
|
72
|
+
"For security, use LOOPS_API_KEY environment variable instead. " +
|
|
73
|
+
"See README.md for details.",
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get the API key, checking environment at call time (not constructor time).
|
|
80
|
+
* This allows the Loops client to be instantiated at module load time.
|
|
81
|
+
*/
|
|
82
|
+
private get apiKey(): string {
|
|
83
|
+
const key = this._apiKey ?? process.env.LOOPS_API_KEY;
|
|
84
|
+
if (!key) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
"Loops API key is required. Set LOOPS_API_KEY in your Convex environment variables.",
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return key;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Add or update a contact in Loops
|
|
94
|
+
*/
|
|
95
|
+
async addContact(ctx: RunActionCtx, contact: ContactData) {
|
|
96
|
+
return ctx.runAction(this.actions.addContact, {
|
|
97
|
+
apiKey: this.apiKey,
|
|
98
|
+
contact,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Update an existing contact in Loops
|
|
104
|
+
*/
|
|
105
|
+
async updateContact(
|
|
106
|
+
ctx: RunActionCtx,
|
|
107
|
+
email: string,
|
|
108
|
+
updates: Partial<ContactData> & {
|
|
109
|
+
dataVariables?: Record<string, unknown>;
|
|
110
|
+
},
|
|
111
|
+
) {
|
|
112
|
+
return ctx.runAction(this.actions.updateContact, {
|
|
113
|
+
apiKey: this.apiKey,
|
|
114
|
+
email,
|
|
115
|
+
...updates,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Send a transactional email using a transactional ID
|
|
121
|
+
*/
|
|
122
|
+
async sendTransactional(
|
|
123
|
+
ctx: RunActionCtx,
|
|
124
|
+
options: TransactionalEmailOptions,
|
|
125
|
+
) {
|
|
126
|
+
return ctx.runAction(this.actions.sendTransactional, {
|
|
127
|
+
apiKey: this.apiKey,
|
|
128
|
+
...options,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Send an event to Loops to trigger email workflows
|
|
134
|
+
*/
|
|
135
|
+
async sendEvent(ctx: RunActionCtx, options: EventOptions) {
|
|
136
|
+
return ctx.runAction(this.actions.sendEvent, {
|
|
137
|
+
apiKey: this.apiKey,
|
|
138
|
+
...options,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Find a contact by email
|
|
144
|
+
* Retrieves contact information from Loops
|
|
145
|
+
*/
|
|
146
|
+
async findContact(ctx: RunActionCtx, email: string) {
|
|
147
|
+
return ctx.runAction(this.actions.findContact, {
|
|
148
|
+
apiKey: this.apiKey,
|
|
149
|
+
email,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Batch create contacts
|
|
155
|
+
* Create multiple contacts in a single API call
|
|
156
|
+
*/
|
|
157
|
+
async batchCreateContacts(ctx: RunActionCtx, contacts: ContactData[]) {
|
|
158
|
+
return ctx.runAction(this.actions.batchCreateContacts, {
|
|
159
|
+
apiKey: this.apiKey,
|
|
160
|
+
contacts,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Unsubscribe a contact
|
|
166
|
+
* Unsubscribes a contact from receiving emails (they remain in the system)
|
|
167
|
+
*/
|
|
168
|
+
async unsubscribeContact(ctx: RunActionCtx, email: string) {
|
|
169
|
+
return ctx.runAction(this.actions.unsubscribeContact, {
|
|
170
|
+
apiKey: this.apiKey,
|
|
171
|
+
email,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Resubscribe a contact
|
|
177
|
+
* Resubscribes a previously unsubscribed contact
|
|
178
|
+
*/
|
|
179
|
+
async resubscribeContact(ctx: RunActionCtx, email: string) {
|
|
180
|
+
return ctx.runAction(this.actions.resubscribeContact, {
|
|
181
|
+
apiKey: this.apiKey,
|
|
182
|
+
email,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Count contacts in the database
|
|
188
|
+
* Can filter by audience criteria (userGroup, source, subscribed status)
|
|
189
|
+
* This queries the component's local database, not Loops API
|
|
190
|
+
*/
|
|
191
|
+
async countContacts(
|
|
192
|
+
ctx: RunQueryCtx,
|
|
193
|
+
options?: {
|
|
194
|
+
userGroup?: string;
|
|
195
|
+
source?: string;
|
|
196
|
+
subscribed?: boolean;
|
|
197
|
+
},
|
|
198
|
+
) {
|
|
199
|
+
return ctx.runQuery(this.queries.countContacts, options ?? {});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* List contacts with cursor-based pagination and optional filters
|
|
204
|
+
* Returns actual contact data, not just a count
|
|
205
|
+
* This queries the component's local database, not Loops API
|
|
206
|
+
*
|
|
207
|
+
* Uses cursor-based pagination for efficiency. Pass the `continueCursor`
|
|
208
|
+
* from the previous response as `cursor` to get the next page.
|
|
209
|
+
*/
|
|
210
|
+
async listContacts(
|
|
211
|
+
ctx: RunQueryCtx,
|
|
212
|
+
options?: {
|
|
213
|
+
userGroup?: string;
|
|
214
|
+
source?: string;
|
|
215
|
+
subscribed?: boolean;
|
|
216
|
+
limit?: number;
|
|
217
|
+
cursor?: string | null;
|
|
218
|
+
},
|
|
219
|
+
) {
|
|
220
|
+
return ctx.runQuery(this.queries.listContacts, {
|
|
221
|
+
userGroup: options?.userGroup,
|
|
222
|
+
source: options?.source,
|
|
223
|
+
subscribed: options?.subscribed,
|
|
224
|
+
limit: options?.limit ?? 100,
|
|
225
|
+
cursor: options?.cursor ?? null,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Detect spam patterns: emails sent to the same recipient too frequently
|
|
231
|
+
*/
|
|
232
|
+
async detectRecipientSpam(
|
|
233
|
+
ctx: RunQueryCtx,
|
|
234
|
+
options?: {
|
|
235
|
+
timeWindowMs?: number;
|
|
236
|
+
maxEmailsPerRecipient?: number;
|
|
237
|
+
},
|
|
238
|
+
) {
|
|
239
|
+
return ctx.runQuery(this.queries.detectRecipientSpam, {
|
|
240
|
+
timeWindowMs: options?.timeWindowMs ?? 3600000,
|
|
241
|
+
maxEmailsPerRecipient: options?.maxEmailsPerRecipient ?? 10,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Detect spam patterns: emails sent by the same actor/user too frequently
|
|
247
|
+
*/
|
|
248
|
+
async detectActorSpam(
|
|
249
|
+
ctx: RunQueryCtx,
|
|
250
|
+
options?: {
|
|
251
|
+
timeWindowMs?: number;
|
|
252
|
+
maxEmailsPerActor?: number;
|
|
253
|
+
},
|
|
254
|
+
) {
|
|
255
|
+
return ctx.runQuery(this.queries.detectActorSpam, {
|
|
256
|
+
timeWindowMs: options?.timeWindowMs ?? 3600000,
|
|
257
|
+
maxEmailsPerActor: options?.maxEmailsPerActor ?? 100,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Get email operation statistics for monitoring
|
|
263
|
+
*/
|
|
264
|
+
async getEmailStats(
|
|
265
|
+
ctx: RunQueryCtx,
|
|
266
|
+
options?: {
|
|
267
|
+
timeWindowMs?: number;
|
|
268
|
+
},
|
|
269
|
+
) {
|
|
270
|
+
return ctx.runQuery(this.queries.getEmailStats, {
|
|
271
|
+
timeWindowMs: options?.timeWindowMs ?? 86400000,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Detect rapid-fire email sending patterns
|
|
277
|
+
*/
|
|
278
|
+
async detectRapidFirePatterns(
|
|
279
|
+
ctx: RunQueryCtx,
|
|
280
|
+
options?: {
|
|
281
|
+
timeWindowMs?: number;
|
|
282
|
+
minEmailsInWindow?: number;
|
|
283
|
+
},
|
|
284
|
+
) {
|
|
285
|
+
return ctx.runQuery(this.queries.detectRapidFirePatterns, {
|
|
286
|
+
timeWindowMs: options?.timeWindowMs ?? 60000,
|
|
287
|
+
minEmailsInWindow: options?.minEmailsInWindow ?? 5,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Check if an email can be sent to a recipient based on rate limits
|
|
293
|
+
*/
|
|
294
|
+
async checkRecipientRateLimit(
|
|
295
|
+
ctx: RunQueryCtx,
|
|
296
|
+
options: {
|
|
297
|
+
email: string;
|
|
298
|
+
timeWindowMs: number;
|
|
299
|
+
maxEmails: number;
|
|
300
|
+
},
|
|
301
|
+
) {
|
|
302
|
+
return ctx.runQuery(this.queries.checkRecipientRateLimit, options);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Check if an actor/user can send more emails based on rate limits
|
|
307
|
+
*/
|
|
308
|
+
async checkActorRateLimit(
|
|
309
|
+
ctx: RunQueryCtx,
|
|
310
|
+
options: {
|
|
311
|
+
actorId: string;
|
|
312
|
+
timeWindowMs: number;
|
|
313
|
+
maxEmails: number;
|
|
314
|
+
},
|
|
315
|
+
) {
|
|
316
|
+
return ctx.runQuery(this.queries.checkActorRateLimit, options);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Check global email sending rate limit
|
|
321
|
+
*/
|
|
322
|
+
async checkGlobalRateLimit(
|
|
323
|
+
ctx: RunQueryCtx,
|
|
324
|
+
options: {
|
|
325
|
+
timeWindowMs: number;
|
|
326
|
+
maxEmails: number;
|
|
327
|
+
},
|
|
328
|
+
) {
|
|
329
|
+
return ctx.runQuery(this.queries.checkGlobalRateLimit, options);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Delete a contact from Loops
|
|
334
|
+
*/
|
|
335
|
+
async deleteContact(ctx: RunActionCtx, email: string) {
|
|
336
|
+
return ctx.runAction(this.actions.deleteContact, {
|
|
337
|
+
apiKey: this.apiKey,
|
|
338
|
+
email,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Trigger a loop for a contact
|
|
344
|
+
* Loops are automated email sequences that can be triggered by events
|
|
345
|
+
*
|
|
346
|
+
* Note: Loops.so doesn't have a direct loop trigger endpoint.
|
|
347
|
+
* Loops are triggered through events. Make sure your loop is configured
|
|
348
|
+
* in the Loops dashboard to listen for events.
|
|
349
|
+
*
|
|
350
|
+
* @param options.eventName - Optional event name. If not provided, uses `loop_{loopId}`
|
|
351
|
+
*/
|
|
352
|
+
async triggerLoop(
|
|
353
|
+
ctx: RunActionCtx,
|
|
354
|
+
options: {
|
|
355
|
+
loopId: string;
|
|
356
|
+
email: string;
|
|
357
|
+
dataVariables?: Record<string, unknown>;
|
|
358
|
+
eventName?: string; // Event name that triggers the loop
|
|
359
|
+
},
|
|
360
|
+
) {
|
|
361
|
+
return ctx.runAction(this.actions.triggerLoop, {
|
|
362
|
+
apiKey: this.apiKey,
|
|
363
|
+
...options,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Backfill the contact aggregate with existing contacts.
|
|
369
|
+
* Run this after upgrading to a version with aggregate support.
|
|
370
|
+
*
|
|
371
|
+
* This processes contacts in batches to avoid timeout issues with large datasets.
|
|
372
|
+
* Call repeatedly with the returned cursor until isDone is true.
|
|
373
|
+
*
|
|
374
|
+
* Usage:
|
|
375
|
+
* ```ts
|
|
376
|
+
* // First call - clear existing aggregate and start backfill
|
|
377
|
+
* let result = await loops.backfillContactAggregate(ctx, { clear: true });
|
|
378
|
+
*
|
|
379
|
+
* // Continue until done
|
|
380
|
+
* while (!result.isDone) {
|
|
381
|
+
* result = await loops.backfillContactAggregate(ctx, { cursor: result.cursor });
|
|
382
|
+
* }
|
|
383
|
+
* ```
|
|
384
|
+
*/
|
|
385
|
+
async backfillContactAggregate(
|
|
386
|
+
ctx: RunMutationCtx,
|
|
387
|
+
options?: {
|
|
388
|
+
cursor?: string | null;
|
|
389
|
+
batchSize?: number;
|
|
390
|
+
clear?: boolean;
|
|
391
|
+
},
|
|
392
|
+
) {
|
|
393
|
+
return ctx.runMutation(this.mutations.backfillContactAggregate, {
|
|
394
|
+
cursor: options?.cursor ?? null,
|
|
395
|
+
batchSize: options?.batchSize ?? 100,
|
|
396
|
+
clear: options?.clear,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* For easy re-exporting.
|
|
402
|
+
* Apps can do
|
|
403
|
+
* ```ts
|
|
404
|
+
* export const { addContact, sendTransactional, sendEvent, triggerLoop } = loops.api();
|
|
405
|
+
* ```
|
|
406
|
+
*/
|
|
407
|
+
api() {
|
|
408
|
+
return {
|
|
409
|
+
addContact: actionGeneric({
|
|
410
|
+
args: {
|
|
411
|
+
email: v.string(),
|
|
412
|
+
firstName: v.optional(v.string()),
|
|
413
|
+
lastName: v.optional(v.string()),
|
|
414
|
+
userId: v.optional(v.string()),
|
|
415
|
+
source: v.optional(v.string()),
|
|
416
|
+
subscribed: v.optional(v.boolean()),
|
|
417
|
+
userGroup: v.optional(v.string()),
|
|
418
|
+
},
|
|
419
|
+
handler: async (ctx, args) => {
|
|
420
|
+
return await this.addContact(ctx, args);
|
|
421
|
+
},
|
|
422
|
+
}),
|
|
423
|
+
updateContact: actionGeneric({
|
|
424
|
+
args: {
|
|
425
|
+
email: v.string(),
|
|
426
|
+
firstName: v.optional(v.string()),
|
|
427
|
+
lastName: v.optional(v.string()),
|
|
428
|
+
userId: v.optional(v.string()),
|
|
429
|
+
source: v.optional(v.string()),
|
|
430
|
+
subscribed: v.optional(v.boolean()),
|
|
431
|
+
userGroup: v.optional(v.string()),
|
|
432
|
+
dataVariables: v.optional(v.any()),
|
|
433
|
+
},
|
|
434
|
+
handler: async (ctx, args) => {
|
|
435
|
+
const { email, ...updates } = args;
|
|
436
|
+
return await this.updateContact(ctx, email, updates);
|
|
437
|
+
},
|
|
438
|
+
}),
|
|
439
|
+
sendTransactional: actionGeneric({
|
|
440
|
+
args: {
|
|
441
|
+
transactionalId: v.string(),
|
|
442
|
+
email: v.string(),
|
|
443
|
+
dataVariables: v.optional(v.any()),
|
|
444
|
+
idempotencyKey: v.optional(v.string()),
|
|
445
|
+
},
|
|
446
|
+
handler: async (ctx, args) => {
|
|
447
|
+
return await this.sendTransactional(ctx, args);
|
|
448
|
+
},
|
|
449
|
+
}),
|
|
450
|
+
sendEvent: actionGeneric({
|
|
451
|
+
args: {
|
|
452
|
+
email: v.string(),
|
|
453
|
+
eventName: v.string(),
|
|
454
|
+
eventProperties: v.optional(v.any()),
|
|
455
|
+
},
|
|
456
|
+
handler: async (ctx, args) => {
|
|
457
|
+
return await this.sendEvent(ctx, args);
|
|
458
|
+
},
|
|
459
|
+
}),
|
|
460
|
+
deleteContact: actionGeneric({
|
|
461
|
+
args: {
|
|
462
|
+
email: v.string(),
|
|
463
|
+
},
|
|
464
|
+
handler: async (ctx, args) => {
|
|
465
|
+
return await this.deleteContact(ctx, args.email);
|
|
466
|
+
},
|
|
467
|
+
}),
|
|
468
|
+
triggerLoop: actionGeneric({
|
|
469
|
+
args: {
|
|
470
|
+
loopId: v.string(),
|
|
471
|
+
email: v.string(),
|
|
472
|
+
dataVariables: v.optional(v.any()),
|
|
473
|
+
},
|
|
474
|
+
handler: async (ctx, args) => {
|
|
475
|
+
return await this.triggerLoop(ctx, args);
|
|
476
|
+
},
|
|
477
|
+
}),
|
|
478
|
+
findContact: actionGeneric({
|
|
479
|
+
args: {
|
|
480
|
+
email: v.string(),
|
|
481
|
+
},
|
|
482
|
+
handler: async (ctx, args) => {
|
|
483
|
+
return await this.findContact(ctx, args.email);
|
|
484
|
+
},
|
|
485
|
+
}),
|
|
486
|
+
batchCreateContacts: actionGeneric({
|
|
487
|
+
args: {
|
|
488
|
+
contacts: v.array(
|
|
489
|
+
v.object({
|
|
490
|
+
email: v.string(),
|
|
491
|
+
firstName: v.optional(v.string()),
|
|
492
|
+
lastName: v.optional(v.string()),
|
|
493
|
+
userId: v.optional(v.string()),
|
|
494
|
+
source: v.optional(v.string()),
|
|
495
|
+
subscribed: v.optional(v.boolean()),
|
|
496
|
+
userGroup: v.optional(v.string()),
|
|
497
|
+
}),
|
|
498
|
+
),
|
|
499
|
+
},
|
|
500
|
+
handler: async (ctx, args) => {
|
|
501
|
+
return await this.batchCreateContacts(ctx, args.contacts);
|
|
502
|
+
},
|
|
503
|
+
}),
|
|
504
|
+
unsubscribeContact: actionGeneric({
|
|
505
|
+
args: {
|
|
506
|
+
email: v.string(),
|
|
507
|
+
},
|
|
508
|
+
handler: async (ctx, args) => {
|
|
509
|
+
return await this.unsubscribeContact(ctx, args.email);
|
|
510
|
+
},
|
|
511
|
+
}),
|
|
512
|
+
resubscribeContact: actionGeneric({
|
|
513
|
+
args: {
|
|
514
|
+
email: v.string(),
|
|
515
|
+
},
|
|
516
|
+
handler: async (ctx, args) => {
|
|
517
|
+
return await this.resubscribeContact(ctx, args.email);
|
|
518
|
+
},
|
|
519
|
+
}),
|
|
520
|
+
countContacts: queryGeneric({
|
|
521
|
+
args: {
|
|
522
|
+
userGroup: v.optional(v.string()),
|
|
523
|
+
source: v.optional(v.string()),
|
|
524
|
+
subscribed: v.optional(v.boolean()),
|
|
525
|
+
},
|
|
526
|
+
handler: async (ctx, args) => {
|
|
527
|
+
return await this.countContacts(ctx, args);
|
|
528
|
+
},
|
|
529
|
+
}),
|
|
530
|
+
listContacts: queryGeneric({
|
|
531
|
+
args: {
|
|
532
|
+
userGroup: v.optional(v.string()),
|
|
533
|
+
source: v.optional(v.string()),
|
|
534
|
+
subscribed: v.optional(v.boolean()),
|
|
535
|
+
limit: v.optional(v.number()),
|
|
536
|
+
cursor: v.optional(v.union(v.string(), v.null())),
|
|
537
|
+
},
|
|
538
|
+
handler: async (ctx, args) => {
|
|
539
|
+
return await this.listContacts(ctx, args);
|
|
540
|
+
},
|
|
541
|
+
}),
|
|
542
|
+
detectRecipientSpam: queryGeneric({
|
|
543
|
+
args: {
|
|
544
|
+
timeWindowMs: v.optional(v.number()),
|
|
545
|
+
maxEmailsPerRecipient: v.optional(v.number()),
|
|
546
|
+
},
|
|
547
|
+
handler: async (ctx, args) => {
|
|
548
|
+
return await this.detectRecipientSpam(ctx, args);
|
|
549
|
+
},
|
|
550
|
+
}),
|
|
551
|
+
detectActorSpam: queryGeneric({
|
|
552
|
+
args: {
|
|
553
|
+
timeWindowMs: v.optional(v.number()),
|
|
554
|
+
maxEmailsPerActor: v.optional(v.number()),
|
|
555
|
+
},
|
|
556
|
+
handler: async (ctx, args) => {
|
|
557
|
+
return await this.detectActorSpam(ctx, args);
|
|
558
|
+
},
|
|
559
|
+
}),
|
|
560
|
+
getEmailStats: queryGeneric({
|
|
561
|
+
args: {
|
|
562
|
+
timeWindowMs: v.optional(v.number()),
|
|
563
|
+
},
|
|
564
|
+
handler: async (ctx, args) => {
|
|
565
|
+
return await this.getEmailStats(ctx, args);
|
|
566
|
+
},
|
|
567
|
+
}),
|
|
568
|
+
detectRapidFirePatterns: queryGeneric({
|
|
569
|
+
args: {
|
|
570
|
+
timeWindowMs: v.optional(v.number()),
|
|
571
|
+
minEmailsInWindow: v.optional(v.number()),
|
|
572
|
+
},
|
|
573
|
+
handler: async (ctx, args) => {
|
|
574
|
+
return await this.detectRapidFirePatterns(ctx, args);
|
|
575
|
+
},
|
|
576
|
+
}),
|
|
577
|
+
checkRecipientRateLimit: queryGeneric({
|
|
578
|
+
args: {
|
|
579
|
+
email: v.string(),
|
|
580
|
+
timeWindowMs: v.number(),
|
|
581
|
+
maxEmails: v.number(),
|
|
582
|
+
},
|
|
583
|
+
handler: async (ctx, args) => {
|
|
584
|
+
return await this.checkRecipientRateLimit(ctx, args);
|
|
585
|
+
},
|
|
586
|
+
}),
|
|
587
|
+
checkActorRateLimit: queryGeneric({
|
|
588
|
+
args: {
|
|
589
|
+
actorId: v.string(),
|
|
590
|
+
timeWindowMs: v.number(),
|
|
591
|
+
maxEmails: v.number(),
|
|
592
|
+
},
|
|
593
|
+
handler: async (ctx, args) => {
|
|
594
|
+
return await this.checkActorRateLimit(ctx, args);
|
|
595
|
+
},
|
|
596
|
+
}),
|
|
597
|
+
checkGlobalRateLimit: queryGeneric({
|
|
598
|
+
args: {
|
|
599
|
+
timeWindowMs: v.number(),
|
|
600
|
+
maxEmails: v.number(),
|
|
601
|
+
},
|
|
602
|
+
handler: async (ctx, args) => {
|
|
603
|
+
return await this.checkGlobalRateLimit(ctx, args);
|
|
604
|
+
},
|
|
605
|
+
}),
|
|
606
|
+
backfillContactAggregate: mutationGeneric({
|
|
607
|
+
args: {
|
|
608
|
+
cursor: v.optional(v.union(v.string(), v.null())),
|
|
609
|
+
batchSize: v.optional(v.number()),
|
|
610
|
+
clear: v.optional(v.boolean()),
|
|
611
|
+
},
|
|
612
|
+
handler: async (ctx, args) => {
|
|
613
|
+
return await this.backfillContactAggregate(ctx, args);
|
|
614
|
+
},
|
|
615
|
+
}),
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
}
|