@devwithbobby/loops 0.1.5 → 0.1.7
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 +45 -24
- package/dist/component/lib.d.ts +3 -0
- package/dist/component/lib.d.ts.map +1 -1
- package/dist/component/lib.js +64 -1
- package/package.json +1 -1
- package/src/component/lib.ts +78 -2
package/README.md
CHANGED
|
@@ -124,8 +124,7 @@ await loops.addContact(ctx, {
|
|
|
124
124
|
#### Update Contact
|
|
125
125
|
|
|
126
126
|
```typescript
|
|
127
|
-
await loops.updateContact(ctx, {
|
|
128
|
-
email: "user@example.com",
|
|
127
|
+
await loops.updateContact(ctx, "user@example.com", {
|
|
129
128
|
firstName: "Jane",
|
|
130
129
|
userGroup: "vip",
|
|
131
130
|
});
|
|
@@ -134,17 +133,13 @@ await loops.updateContact(ctx, {
|
|
|
134
133
|
#### Find Contact
|
|
135
134
|
|
|
136
135
|
```typescript
|
|
137
|
-
const contact = await loops.findContact(ctx,
|
|
138
|
-
email: "user@example.com",
|
|
139
|
-
});
|
|
136
|
+
const contact = await loops.findContact(ctx, "user@example.com");
|
|
140
137
|
```
|
|
141
138
|
|
|
142
139
|
#### Delete Contact
|
|
143
140
|
|
|
144
141
|
```typescript
|
|
145
|
-
await loops.deleteContact(ctx,
|
|
146
|
-
email: "user@example.com",
|
|
147
|
-
});
|
|
142
|
+
await loops.deleteContact(ctx, "user@example.com");
|
|
148
143
|
```
|
|
149
144
|
|
|
150
145
|
#### Batch Create Contacts
|
|
@@ -161,8 +156,8 @@ await loops.batchCreateContacts(ctx, {
|
|
|
161
156
|
#### Unsubscribe/Resubscribe
|
|
162
157
|
|
|
163
158
|
```typescript
|
|
164
|
-
await loops.unsubscribeContact(ctx,
|
|
165
|
-
await loops.resubscribeContact(ctx,
|
|
159
|
+
await loops.unsubscribeContact(ctx, "user@example.com");
|
|
160
|
+
await loops.resubscribeContact(ctx, "user@example.com");
|
|
166
161
|
```
|
|
167
162
|
|
|
168
163
|
#### Count Contacts
|
|
@@ -383,33 +378,59 @@ export const addContact = action({
|
|
|
383
378
|
## Security Best Practices
|
|
384
379
|
|
|
385
380
|
1. **Always add authentication** - Wrap all functions with auth checks
|
|
386
|
-
2. **Use environment variables** - Store API key in Convex environment variables
|
|
387
|
-
3. **Implement rate limiting** - Use the built-in rate limiting queries
|
|
381
|
+
2. **Use environment variables** - Store API key in Convex environment variables (never hardcode)
|
|
382
|
+
3. **Implement rate limiting** - Use the built-in rate limiting queries to prevent abuse
|
|
388
383
|
4. **Monitor for abuse** - Use spam detection queries to identify suspicious patterns
|
|
389
384
|
5. **Sanitize errors** - Don't expose sensitive error details to clients
|
|
390
385
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
## Monitoring & Rate Limiting
|
|
394
|
-
|
|
395
|
-
The component automatically logs all email operations for monitoring. Use the built-in queries to:
|
|
386
|
+
### Authentication Example
|
|
396
387
|
|
|
397
|
-
|
|
398
|
-
- Detect spam patterns
|
|
399
|
-
- Enforce rate limits
|
|
400
|
-
- Monitor for abuse
|
|
388
|
+
All functions should be wrapped with authentication:
|
|
401
389
|
|
|
402
|
-
|
|
390
|
+
```typescript
|
|
391
|
+
export const addContact = action({
|
|
392
|
+
args: { email: v.string(), ... },
|
|
393
|
+
handler: async (ctx, args) => {
|
|
394
|
+
// Add authentication check
|
|
395
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
396
|
+
if (!identity) throw new Error("Unauthorized");
|
|
397
|
+
|
|
398
|
+
// Add authorization checks if needed
|
|
399
|
+
// if (!isAdmin(identity)) throw new Error("Forbidden");
|
|
400
|
+
|
|
401
|
+
return await loops.addContact(ctx, args);
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
```
|
|
403
405
|
|
|
404
|
-
|
|
406
|
+
### Environment Variables
|
|
405
407
|
|
|
406
408
|
Set `LOOPS_API_KEY` in your Convex environment:
|
|
407
409
|
|
|
410
|
+
**Via CLI:**
|
|
408
411
|
```bash
|
|
409
412
|
npx convex env set LOOPS_API_KEY "your-api-key"
|
|
410
413
|
```
|
|
411
414
|
|
|
412
|
-
|
|
415
|
+
**Via Dashboard:**
|
|
416
|
+
1. Go to your Convex Dashboard
|
|
417
|
+
2. Navigate to Settings → Environment Variables
|
|
418
|
+
3. Add `LOOPS_API_KEY` with your Loops.so API key value
|
|
419
|
+
|
|
420
|
+
Get your API key from [Loops.so Dashboard](https://app.loops.so/settings/api).
|
|
421
|
+
|
|
422
|
+
⚠️ **Never** pass the API key directly in code or via function options in production. Always use environment variables.
|
|
423
|
+
|
|
424
|
+
## Monitoring & Rate Limiting
|
|
425
|
+
|
|
426
|
+
The component automatically logs all email operations to the `emailOperations` table for monitoring. Use the built-in queries to:
|
|
427
|
+
|
|
428
|
+
- **Track email statistics** - See total sends, success/failure rates, breakdowns by type
|
|
429
|
+
- **Detect spam patterns** - Identify suspicious activity by recipient or actor
|
|
430
|
+
- **Enforce rate limits** - Prevent abuse with recipient, actor, or global rate limits
|
|
431
|
+
- **Monitor for abuse** - Detect rapid-fire patterns and unusual sending behavior
|
|
432
|
+
|
|
433
|
+
All monitoring queries are available through the `Loops` client - see the [Monitoring & Analytics](#monitoring--analytics) section above for usage examples.
|
|
413
434
|
|
|
414
435
|
## Development
|
|
415
436
|
|
package/dist/component/lib.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export declare const logEmailOperation: any;
|
|
|
17
17
|
export declare const countContacts: any;
|
|
18
18
|
/**
|
|
19
19
|
* Add or update a contact in Loops
|
|
20
|
+
* This function tries to create a contact, and if the email already exists (409),
|
|
21
|
+
* it falls back to updating the contact instead.
|
|
20
22
|
*/
|
|
21
23
|
export declare const addContact: any;
|
|
22
24
|
/**
|
|
@@ -49,6 +51,7 @@ export declare const triggerLoop: any;
|
|
|
49
51
|
/**
|
|
50
52
|
* Find a contact by email
|
|
51
53
|
* Retrieves contact information from Loops
|
|
54
|
+
* Note: Loops API may return either an object or an array
|
|
52
55
|
*/
|
|
53
56
|
export declare const findContact: any;
|
|
54
57
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lib.d.ts","sourceRoot":"","sources":["../../src/component/lib.ts"],"names":[],"mappings":"AA0BA;;GAEG;AACH,eAAO,MAAM,YAAY,KA6CvB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,aAAa,KAexB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,iBAAiB,KAgC5B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,aAAa,KAwCxB,CAAC;AAEH
|
|
1
|
+
{"version":3,"file":"lib.d.ts","sourceRoot":"","sources":["../../src/component/lib.ts"],"names":[],"mappings":"AA0BA;;GAEG;AACH,eAAO,MAAM,YAAY,KA6CvB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,aAAa,KAexB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,iBAAiB,KAgC5B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,aAAa,KAwCxB,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,UAAU,KAiHrB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,aAAa,KAoDxB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,iBAAiB,KAqD5B,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,SAAS,KAgCpB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,aAAa,KA8BxB,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,YAAY,KAkFvB,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,WAAW,KA+CtB,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,WAAW,KAoDtB,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,mBAAmB,KA4C9B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,kBAAkB,KA+B7B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,kBAAkB,KA+B7B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,mBAAmB,KAwC9B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,eAAe,KAwC1B,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,aAAa,KAgDxB,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,uBAAuB,KAsGlC,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,uBAAuB,KA6ClC,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,mBAAmB,KA6C9B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,oBAAoB,KA8B/B,CAAC"}
|
package/dist/component/lib.js
CHANGED
|
@@ -177,6 +177,8 @@ export const countContacts = zq({
|
|
|
177
177
|
});
|
|
178
178
|
/**
|
|
179
179
|
* Add or update a contact in Loops
|
|
180
|
+
* This function tries to create a contact, and if the email already exists (409),
|
|
181
|
+
* it falls back to updating the contact instead.
|
|
180
182
|
*/
|
|
181
183
|
export const addContact = za({
|
|
182
184
|
args: z.object({
|
|
@@ -198,9 +200,67 @@ export const addContact = za({
|
|
|
198
200
|
});
|
|
199
201
|
if (!response.ok) {
|
|
200
202
|
const errorText = await response.text();
|
|
203
|
+
if (response.status === 409) {
|
|
204
|
+
console.log(`Contact ${args.contact.email} already exists, updating instead`);
|
|
205
|
+
const findResponse = await fetch(`${LOOPS_API_BASE_URL}/contacts/find?email=${encodeURIComponent(args.contact.email)}`, {
|
|
206
|
+
method: "GET",
|
|
207
|
+
headers: {
|
|
208
|
+
Authorization: `Bearer ${args.apiKey}`,
|
|
209
|
+
"Content-Type": "application/json",
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
if (!findResponse.ok) {
|
|
213
|
+
const findErrorText = await findResponse.text();
|
|
214
|
+
console.error(`Failed to find existing contact [${findResponse.status}]:`, findErrorText);
|
|
215
|
+
}
|
|
216
|
+
const updateResponse = await fetch(`${LOOPS_API_BASE_URL}/contacts/update`, {
|
|
217
|
+
method: "PUT",
|
|
218
|
+
headers: {
|
|
219
|
+
Authorization: `Bearer ${args.apiKey}`,
|
|
220
|
+
"Content-Type": "application/json",
|
|
221
|
+
},
|
|
222
|
+
body: JSON.stringify({
|
|
223
|
+
email: args.contact.email,
|
|
224
|
+
firstName: args.contact.firstName,
|
|
225
|
+
lastName: args.contact.lastName,
|
|
226
|
+
userId: args.contact.userId,
|
|
227
|
+
source: args.contact.source,
|
|
228
|
+
subscribed: args.contact.subscribed,
|
|
229
|
+
userGroup: args.contact.userGroup,
|
|
230
|
+
}),
|
|
231
|
+
});
|
|
232
|
+
if (!updateResponse.ok) {
|
|
233
|
+
const updateErrorText = await updateResponse.text();
|
|
234
|
+
console.error(`Loops API error [${updateResponse.status}]:`, updateErrorText);
|
|
235
|
+
throw sanitizeError(updateResponse.status, updateErrorText);
|
|
236
|
+
}
|
|
237
|
+
// Get contact ID if available
|
|
238
|
+
let contactId;
|
|
239
|
+
if (findResponse.ok) {
|
|
240
|
+
const findData = (await findResponse.json());
|
|
241
|
+
contactId = findData.id;
|
|
242
|
+
}
|
|
243
|
+
// Store/update in our database
|
|
244
|
+
await ctx.runMutation((internal.lib).storeContact, {
|
|
245
|
+
email: args.contact.email,
|
|
246
|
+
firstName: args.contact.firstName,
|
|
247
|
+
lastName: args.contact.lastName,
|
|
248
|
+
userId: args.contact.userId,
|
|
249
|
+
source: args.contact.source,
|
|
250
|
+
subscribed: args.contact.subscribed,
|
|
251
|
+
userGroup: args.contact.userGroup,
|
|
252
|
+
loopsContactId: contactId,
|
|
253
|
+
});
|
|
254
|
+
return {
|
|
255
|
+
success: true,
|
|
256
|
+
id: contactId,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
// For other errors, throw as normal
|
|
201
260
|
console.error(`Loops API error [${response.status}]:`, errorText);
|
|
202
261
|
throw sanitizeError(response.status, errorText);
|
|
203
262
|
}
|
|
263
|
+
// Contact was created successfully
|
|
204
264
|
const data = (await response.json());
|
|
205
265
|
await ctx.runMutation((internal.lib).storeContact, {
|
|
206
266
|
email: args.contact.email,
|
|
@@ -518,6 +578,7 @@ export const triggerLoop = za({
|
|
|
518
578
|
/**
|
|
519
579
|
* Find a contact by email
|
|
520
580
|
* Retrieves contact information from Loops
|
|
581
|
+
* Note: Loops API may return either an object or an array
|
|
521
582
|
*/
|
|
522
583
|
export const findContact = za({
|
|
523
584
|
args: z.object({
|
|
@@ -557,9 +618,11 @@ export const findContact = za({
|
|
|
557
618
|
throw sanitizeError(response.status, errorText);
|
|
558
619
|
}
|
|
559
620
|
const data = (await response.json());
|
|
621
|
+
// Handle case where Loops returns an array instead of a single object
|
|
622
|
+
const contact = Array.isArray(data) ? data[0] : data;
|
|
560
623
|
return {
|
|
561
624
|
success: true,
|
|
562
|
-
contact:
|
|
625
|
+
contact: contact,
|
|
563
626
|
};
|
|
564
627
|
},
|
|
565
628
|
});
|
package/package.json
CHANGED
package/src/component/lib.ts
CHANGED
|
@@ -179,6 +179,8 @@ export const countContacts = zq({
|
|
|
179
179
|
|
|
180
180
|
/**
|
|
181
181
|
* Add or update a contact in Loops
|
|
182
|
+
* This function tries to create a contact, and if the email already exists (409),
|
|
183
|
+
* it falls back to updating the contact instead.
|
|
182
184
|
*/
|
|
183
185
|
export const addContact = za({
|
|
184
186
|
args: z.object({
|
|
@@ -201,10 +203,80 @@ export const addContact = za({
|
|
|
201
203
|
|
|
202
204
|
if (!response.ok) {
|
|
203
205
|
const errorText = await response.text();
|
|
206
|
+
|
|
207
|
+
if (response.status === 409) {
|
|
208
|
+
console.log(`Contact ${args.contact.email} already exists, updating instead`);
|
|
209
|
+
|
|
210
|
+
const findResponse = await fetch(
|
|
211
|
+
`${LOOPS_API_BASE_URL}/contacts/find?email=${encodeURIComponent(args.contact.email)}`,
|
|
212
|
+
{
|
|
213
|
+
method: "GET",
|
|
214
|
+
headers: {
|
|
215
|
+
Authorization: `Bearer ${args.apiKey}`,
|
|
216
|
+
"Content-Type": "application/json",
|
|
217
|
+
},
|
|
218
|
+
}
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
if (!findResponse.ok) {
|
|
222
|
+
const findErrorText = await findResponse.text();
|
|
223
|
+
console.error(`Failed to find existing contact [${findResponse.status}]:`, findErrorText);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const updateResponse = await fetch(`${LOOPS_API_BASE_URL}/contacts/update`, {
|
|
227
|
+
method: "PUT",
|
|
228
|
+
headers: {
|
|
229
|
+
Authorization: `Bearer ${args.apiKey}`,
|
|
230
|
+
"Content-Type": "application/json",
|
|
231
|
+
},
|
|
232
|
+
body: JSON.stringify({
|
|
233
|
+
email: args.contact.email,
|
|
234
|
+
firstName: args.contact.firstName,
|
|
235
|
+
lastName: args.contact.lastName,
|
|
236
|
+
userId: args.contact.userId,
|
|
237
|
+
source: args.contact.source,
|
|
238
|
+
subscribed: args.contact.subscribed,
|
|
239
|
+
userGroup: args.contact.userGroup,
|
|
240
|
+
}),
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
if (!updateResponse.ok) {
|
|
244
|
+
const updateErrorText = await updateResponse.text();
|
|
245
|
+
console.error(`Loops API error [${updateResponse.status}]:`, updateErrorText);
|
|
246
|
+
throw sanitizeError(updateResponse.status, updateErrorText);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Get contact ID if available
|
|
250
|
+
let contactId: string | undefined;
|
|
251
|
+
if (findResponse.ok) {
|
|
252
|
+
const findData = (await findResponse.json()) as { id?: string };
|
|
253
|
+
contactId = findData.id;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Store/update in our database
|
|
257
|
+
await ctx.runMutation(((internal as any).lib).storeContact as any, {
|
|
258
|
+
email: args.contact.email,
|
|
259
|
+
firstName: args.contact.firstName,
|
|
260
|
+
lastName: args.contact.lastName,
|
|
261
|
+
userId: args.contact.userId,
|
|
262
|
+
source: args.contact.source,
|
|
263
|
+
subscribed: args.contact.subscribed,
|
|
264
|
+
userGroup: args.contact.userGroup,
|
|
265
|
+
loopsContactId: contactId,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
success: true,
|
|
270
|
+
id: contactId,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// For other errors, throw as normal
|
|
204
275
|
console.error(`Loops API error [${response.status}]:`, errorText);
|
|
205
276
|
throw sanitizeError(response.status, errorText);
|
|
206
277
|
}
|
|
207
278
|
|
|
279
|
+
// Contact was created successfully
|
|
208
280
|
const data = (await response.json()) as { id?: string };
|
|
209
281
|
|
|
210
282
|
await ctx.runMutation(((internal as any).lib).storeContact as any, {
|
|
@@ -557,6 +629,7 @@ export const triggerLoop = za({
|
|
|
557
629
|
/**
|
|
558
630
|
* Find a contact by email
|
|
559
631
|
* Retrieves contact information from Loops
|
|
632
|
+
* Note: Loops API may return either an object or an array
|
|
560
633
|
*/
|
|
561
634
|
export const findContact = za({
|
|
562
635
|
args: z.object({
|
|
@@ -600,11 +673,14 @@ export const findContact = za({
|
|
|
600
673
|
throw sanitizeError(response.status, errorText);
|
|
601
674
|
}
|
|
602
675
|
|
|
603
|
-
const data = (await response.json()) as Record<string, any
|
|
676
|
+
const data = (await response.json()) as Record<string, any> | Array<Record<string, any>>;
|
|
677
|
+
|
|
678
|
+
// Handle case where Loops returns an array instead of a single object
|
|
679
|
+
const contact = Array.isArray(data) ? data[0] : data;
|
|
604
680
|
|
|
605
681
|
return {
|
|
606
682
|
success: true,
|
|
607
|
-
contact:
|
|
683
|
+
contact: contact as Record<string, any> | undefined,
|
|
608
684
|
};
|
|
609
685
|
},
|
|
610
686
|
});
|