@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
package/README.md
CHANGED
|
@@ -1,42 +1,49 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @devwithbobby/loops
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/@devwithbobby/loops)
|
|
4
4
|
|
|
5
|
-
A
|
|
5
|
+
A Convex component for integrating with [Loops.so](https://loops.so) email marketing platform. Send transactional emails, manage contacts, trigger campaigns and loops, and monitor email operations with built-in spam detection and rate limiting.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## Features
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
- ✅ **Contact Management** - Create, update, find, and delete contacts
|
|
10
|
+
- ✅ **Transactional Emails** - Send one-off emails with templates
|
|
11
|
+
- ✅ **Events** - Trigger email workflows based on events
|
|
12
|
+
- ✅ **Campaigns** - Send campaigns to audiences or specific contacts
|
|
13
|
+
- ✅ **Loops** - Trigger automated email sequences
|
|
14
|
+
- ✅ **Monitoring** - Track all email operations with spam detection
|
|
15
|
+
- ✅ **Rate Limiting** - Built-in rate limiting queries for abuse prevention
|
|
16
|
+
- ✅ **Type-Safe** - Full TypeScript support with Zod validation
|
|
10
17
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
After cloning this template, run the rename script to customize it for your component:
|
|
18
|
+
## Installation
|
|
14
19
|
|
|
15
20
|
```bash
|
|
16
|
-
|
|
21
|
+
npm install @devwithbobby/loops
|
|
22
|
+
# or
|
|
23
|
+
bun add @devwithbobby/loops
|
|
17
24
|
```
|
|
18
25
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
### 1. Install and Mount the Component
|
|
29
|
+
|
|
30
|
+
In your `convex/convex.config.ts`:
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
import loops from "@devwithbobby/loops/convex.config";
|
|
34
|
+
import { defineApp } from "convex/server";
|
|
35
|
+
|
|
36
|
+
const app = defineApp();
|
|
37
|
+
app.use(loops);
|
|
38
|
+
|
|
39
|
+
export default app;
|
|
40
|
+
```
|
|
33
41
|
|
|
34
42
|
### 2. Set Up Environment Variables
|
|
35
43
|
|
|
36
44
|
**⚠️ IMPORTANT: Set your Loops API key before using the component.**
|
|
37
45
|
|
|
38
46
|
```bash
|
|
39
|
-
# Set the API key in your Convex environment variables
|
|
40
47
|
npx convex env set LOOPS_API_KEY "your-loops-api-key-here"
|
|
41
48
|
```
|
|
42
49
|
|
|
@@ -44,474 +51,435 @@ npx convex env set LOOPS_API_KEY "your-loops-api-key-here"
|
|
|
44
51
|
1. Go to Settings → Environment Variables
|
|
45
52
|
2. Add `LOOPS_API_KEY` with your Loops.so API key
|
|
46
53
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
### 3. Install Dependencies & Start Development
|
|
50
|
-
|
|
51
|
-
```bash
|
|
52
|
-
bun install
|
|
53
|
-
cd example && bun install && cd ..
|
|
54
|
-
bun run dev:backend
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
Then in another terminal:
|
|
58
|
-
```bash
|
|
59
|
-
cd example
|
|
60
|
-
bun run dev
|
|
61
|
-
```
|
|
54
|
+
Get your API key from [Loops.so Dashboard](https://app.loops.so/settings/api).
|
|
62
55
|
|
|
63
|
-
|
|
56
|
+
### 3. Use the Component
|
|
64
57
|
|
|
65
|
-
|
|
66
|
-
- Functions (queries, mutations, actions)
|
|
67
|
-
- Tables and schemas
|
|
68
|
-
- File storage
|
|
69
|
-
- Scheduled functions
|
|
58
|
+
In your `convex/functions.ts` (or any convex file):
|
|
70
59
|
|
|
71
|
-
|
|
60
|
+
```typescript
|
|
61
|
+
import { Loops } from "@devwithbobby/loops";
|
|
62
|
+
import { components } from "./_generated/api";
|
|
63
|
+
import { action } from "./_generated/server";
|
|
64
|
+
import { v } from "convex/values";
|
|
65
|
+
|
|
66
|
+
// Initialize the Loops client
|
|
67
|
+
const loops = new Loops(components.loops);
|
|
68
|
+
|
|
69
|
+
// Export functions wrapped with auth (required in production)
|
|
70
|
+
export const addContact = action({
|
|
71
|
+
args: {
|
|
72
|
+
email: v.string(),
|
|
73
|
+
firstName: v.optional(v.string()),
|
|
74
|
+
lastName: v.optional(v.string()),
|
|
75
|
+
},
|
|
76
|
+
handler: async (ctx, args) => {
|
|
77
|
+
// Add authentication check
|
|
78
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
79
|
+
if (!identity) throw new Error("Unauthorized");
|
|
72
80
|
|
|
73
|
-
|
|
81
|
+
return await loops.addContact(ctx, args);
|
|
82
|
+
},
|
|
83
|
+
});
|
|
74
84
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
example/
|
|
95
|
-
convex/ # Example app that uses the component
|
|
96
|
-
convex.config.ts # Example app configuration
|
|
97
|
-
schema.ts # Example app schema
|
|
98
|
-
_generated/ # Auto-generated types (gitignored)
|
|
99
|
-
src/ # Example app frontend
|
|
85
|
+
export const sendWelcomeEmail = action({
|
|
86
|
+
args: {
|
|
87
|
+
email: v.string(),
|
|
88
|
+
name: v.string(),
|
|
89
|
+
},
|
|
90
|
+
handler: async (ctx, args) => {
|
|
91
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
92
|
+
if (!identity) throw new Error("Unauthorized");
|
|
93
|
+
|
|
94
|
+
// Send transactional email
|
|
95
|
+
return await loops.sendTransactional(ctx, {
|
|
96
|
+
transactionalId: "welcome-email-template-id",
|
|
97
|
+
email: args.email,
|
|
98
|
+
dataVariables: {
|
|
99
|
+
name: args.name,
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
});
|
|
100
104
|
```
|
|
101
105
|
|
|
102
|
-
##
|
|
106
|
+
## API Reference
|
|
103
107
|
|
|
104
|
-
###
|
|
105
|
-
```bash
|
|
106
|
-
bun run dev:backend # Start Convex dev with live component sources
|
|
107
|
-
bun run build # Build the component for distribution
|
|
108
|
-
```
|
|
108
|
+
### Contact Management
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
```bash
|
|
112
|
-
bun test # Run all tests
|
|
113
|
-
bun test --watch # Watch mode
|
|
114
|
-
bun test --coverage # Generate coverage reports
|
|
115
|
-
bun test -t "pattern" # Filter tests by name
|
|
116
|
-
CLAUDECODE=1 bun test # AI-friendly quiet output
|
|
117
|
-
```
|
|
110
|
+
#### Add or Update Contact
|
|
118
111
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
112
|
+
```typescript
|
|
113
|
+
await loops.addContact(ctx, {
|
|
114
|
+
email: "user@example.com",
|
|
115
|
+
firstName: "John",
|
|
116
|
+
lastName: "Doe",
|
|
117
|
+
userId: "user123",
|
|
118
|
+
source: "webapp",
|
|
119
|
+
subscribed: true,
|
|
120
|
+
userGroup: "premium",
|
|
121
|
+
});
|
|
126
122
|
```
|
|
127
123
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
This project uses [Lefthook](https://github.com/evilmartians/lefthook) for Git hooks. Hooks are automatically installed when you run `bun install`.
|
|
124
|
+
#### Update Contact
|
|
131
125
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
```bash
|
|
139
|
-
git commit --no-verify
|
|
126
|
+
```typescript
|
|
127
|
+
await loops.updateContact(ctx, {
|
|
128
|
+
email: "user@example.com",
|
|
129
|
+
firstName: "Jane",
|
|
130
|
+
userGroup: "vip",
|
|
131
|
+
});
|
|
140
132
|
```
|
|
141
133
|
|
|
142
|
-
|
|
134
|
+
#### Find Contact
|
|
143
135
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
- Publishes preview packages with `pkg.pr.new`
|
|
150
|
-
- Runs all tests
|
|
151
|
-
- Runs linting checks
|
|
152
|
-
|
|
153
|
-
The workflow ensures code quality and prevents broken builds from being merged.
|
|
154
|
-
|
|
155
|
-
#### pkg.pr.new Setup
|
|
156
|
-
|
|
157
|
-
This project uses [pkg.pr.new](https://github.com/stackblitz-labs/pkg.pr.new) for continuous package previews. Each commit and PR automatically generates a preview release that can be installed without publishing to npm.
|
|
158
|
-
|
|
159
|
-
**One-time setup required:**
|
|
160
|
-
1. Install the [pkg.pr.new GitHub App](https://github.com/apps/pkg-pr-new) on your repository
|
|
161
|
-
2. Once installed, the workflow will automatically publish preview packages on every commit/PR
|
|
136
|
+
```typescript
|
|
137
|
+
const contact = await loops.findContact(ctx, {
|
|
138
|
+
email: "user@example.com",
|
|
139
|
+
});
|
|
140
|
+
```
|
|
162
141
|
|
|
163
|
-
|
|
164
|
-
```bash
|
|
165
|
-
# Install from a specific commit (Bun)
|
|
166
|
-
bun add https://pkg.pr.new/robertalv/loops-component/robertalv/loops-component@COMMIT_SHA
|
|
142
|
+
#### Delete Contact
|
|
167
143
|
|
|
168
|
-
|
|
169
|
-
|
|
144
|
+
```typescript
|
|
145
|
+
await loops.deleteContact(ctx, {
|
|
146
|
+
email: "user@example.com",
|
|
147
|
+
});
|
|
170
148
|
```
|
|
171
149
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
## Component Architecture
|
|
150
|
+
#### Batch Create Contacts
|
|
175
151
|
|
|
176
|
-
|
|
152
|
+
```typescript
|
|
153
|
+
await loops.batchCreateContacts(ctx, {
|
|
154
|
+
contacts: [
|
|
155
|
+
{ email: "user1@example.com", firstName: "John" },
|
|
156
|
+
{ email: "user2@example.com", firstName: "Jane" },
|
|
157
|
+
],
|
|
158
|
+
});
|
|
159
|
+
```
|
|
177
160
|
|
|
178
|
-
|
|
161
|
+
#### Unsubscribe/Resubscribe
|
|
179
162
|
|
|
180
163
|
```typescript
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const component = defineComponent("loopsComponent"); // Change "loopsComponent" to your component name
|
|
185
|
-
component.export(api, { greet: api.lib.greet });
|
|
186
|
-
export default component;
|
|
164
|
+
await loops.unsubscribeContact(ctx, { email: "user@example.com" });
|
|
165
|
+
await loops.resubscribeContact(ctx, { email: "user@example.com" });
|
|
187
166
|
```
|
|
188
167
|
|
|
189
|
-
|
|
168
|
+
#### Count Contacts
|
|
190
169
|
|
|
191
|
-
|
|
170
|
+
```typescript
|
|
171
|
+
// Count all contacts
|
|
172
|
+
const total = await loops.countContacts(ctx, {});
|
|
192
173
|
|
|
193
|
-
|
|
194
|
-
{
|
|
195
|
-
"
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}
|
|
174
|
+
// Count by filter
|
|
175
|
+
const premium = await loops.countContacts(ctx, {
|
|
176
|
+
userGroup: "premium",
|
|
177
|
+
subscribed: true,
|
|
178
|
+
});
|
|
201
179
|
```
|
|
202
180
|
|
|
203
|
-
|
|
181
|
+
### Email Sending
|
|
204
182
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
Apps import and mount the component in their `convex.config.ts`:
|
|
183
|
+
#### Send Transactional Email
|
|
208
184
|
|
|
209
185
|
```typescript
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
186
|
+
await loops.sendTransactional(ctx, {
|
|
187
|
+
transactionalId: "template-id-from-loops",
|
|
188
|
+
email: "user@example.com",
|
|
189
|
+
dataVariables: {
|
|
190
|
+
name: "John",
|
|
191
|
+
orderId: "12345",
|
|
192
|
+
},
|
|
193
|
+
});
|
|
216
194
|
```
|
|
217
195
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
The `src/client/` directory contains helper code that runs in the app (not the component):
|
|
196
|
+
#### Send Event (Triggers Workflows)
|
|
221
197
|
|
|
222
198
|
```typescript
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
}
|
|
232
|
-
}
|
|
199
|
+
await loops.sendEvent(ctx, {
|
|
200
|
+
email: "user@example.com",
|
|
201
|
+
eventName: "purchase_completed",
|
|
202
|
+
eventProperties: {
|
|
203
|
+
product: "Premium Plan",
|
|
204
|
+
amount: 99.99,
|
|
205
|
+
},
|
|
206
|
+
});
|
|
233
207
|
```
|
|
234
208
|
|
|
235
|
-
|
|
236
|
-
- Hiding implementation details
|
|
237
|
-
- Managing implicit dependencies (auth, env vars)
|
|
238
|
-
- Providing a cleaner API surface
|
|
209
|
+
#### Send Campaign
|
|
239
210
|
|
|
240
|
-
|
|
211
|
+
```typescript
|
|
212
|
+
// Send to specific emails
|
|
213
|
+
await loops.sendCampaign(ctx, {
|
|
214
|
+
campaignId: "campaign-id-from-loops",
|
|
215
|
+
emails: ["user1@example.com", "user2@example.com"],
|
|
216
|
+
dataVariables: { discount: "20%" },
|
|
217
|
+
});
|
|
241
218
|
|
|
242
|
-
|
|
219
|
+
// Send to audience
|
|
220
|
+
await loops.sendCampaign(ctx, {
|
|
221
|
+
campaignId: "campaign-id-from-loops",
|
|
222
|
+
audienceFilters: {
|
|
223
|
+
userGroup: "premium",
|
|
224
|
+
source: "webapp",
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
```
|
|
243
228
|
|
|
244
|
-
|
|
229
|
+
#### Trigger Loop (Automated Sequence)
|
|
245
230
|
|
|
246
231
|
```typescript
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
232
|
+
await loops.triggerLoop(ctx, {
|
|
233
|
+
loopId: "loop-id-from-loops",
|
|
234
|
+
email: "user@example.com",
|
|
235
|
+
dataVariables: {
|
|
236
|
+
onboardingStep: "welcome",
|
|
237
|
+
},
|
|
238
|
+
});
|
|
250
239
|
```
|
|
251
240
|
|
|
252
|
-
|
|
253
|
-
1. Sub-queries track reads for reactivity across components
|
|
254
|
-
2. Sub-mutations contribute to the parent's ACID transaction
|
|
255
|
-
3. Sub-mutations are isolated from each other (even with `Promise.all`)
|
|
256
|
-
4. Parent errors roll back sub-mutations
|
|
257
|
-
5. Sub-mutation errors can be caught without affecting parent
|
|
241
|
+
### Monitoring & Analytics
|
|
258
242
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
Components cannot be called directly from clients. The app must wrap them:
|
|
243
|
+
#### Get Email Statistics
|
|
262
244
|
|
|
263
245
|
```typescript
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
handler: async (ctx) => {
|
|
267
|
-
return await ctx.runQuery(components.counter.public.count, {});
|
|
268
|
-
},
|
|
246
|
+
const stats = await loops.getEmailStats(ctx, {
|
|
247
|
+
timeWindowMs: 3600000, // Last hour
|
|
269
248
|
});
|
|
249
|
+
|
|
250
|
+
console.log(stats.totalOperations); // Total emails sent
|
|
251
|
+
console.log(stats.successfulOperations); // Successful sends
|
|
252
|
+
console.log(stats.failedOperations); // Failed sends
|
|
253
|
+
console.log(stats.operationsByType); // Breakdown by type
|
|
254
|
+
console.log(stats.uniqueRecipients); // Unique email addresses
|
|
270
255
|
```
|
|
271
256
|
|
|
272
|
-
|
|
257
|
+
#### Detect Spam Patterns
|
|
273
258
|
|
|
274
|
-
|
|
259
|
+
```typescript
|
|
260
|
+
// Detect recipients with suspicious activity
|
|
261
|
+
const spamRecipients = await loops.detectRecipientSpam(ctx, {
|
|
262
|
+
timeWindowMs: 3600000,
|
|
263
|
+
maxEmailsPerRecipient: 10,
|
|
264
|
+
});
|
|
275
265
|
|
|
276
|
-
|
|
266
|
+
// Detect actors with suspicious activity
|
|
267
|
+
const spamActors = await loops.detectActorSpam(ctx, {
|
|
268
|
+
timeWindowMs: 3600000,
|
|
269
|
+
maxEmailsPerActor: 50,
|
|
270
|
+
});
|
|
277
271
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
C --> E[Component3]
|
|
272
|
+
// Detect rapid-fire patterns
|
|
273
|
+
const rapidFire = await loops.detectRapidFirePatterns(ctx, {
|
|
274
|
+
timeWindowMs: 60000, // Last minute
|
|
275
|
+
maxEmailsPerWindow: 5,
|
|
276
|
+
});
|
|
284
277
|
```
|
|
285
278
|
|
|
286
|
-
|
|
287
|
-
- Apps can call their own functions and component public functions
|
|
288
|
-
- Components can only call their own functions and child component public functions
|
|
289
|
-
|
|
290
|
-
### Function Handles
|
|
279
|
+
### Rate Limiting
|
|
291
280
|
|
|
292
|
-
|
|
281
|
+
#### Check Rate Limits
|
|
293
282
|
|
|
294
283
|
```typescript
|
|
295
|
-
//
|
|
296
|
-
const
|
|
284
|
+
// Check recipient rate limit
|
|
285
|
+
const recipientCheck = await loops.checkRecipientRateLimit(ctx, {
|
|
286
|
+
email: "user@example.com",
|
|
287
|
+
timeWindowMs: 3600000, // 1 hour
|
|
288
|
+
maxEmails: 10,
|
|
289
|
+
});
|
|
297
290
|
|
|
298
|
-
|
|
299
|
-
|
|
291
|
+
if (!recipientCheck.allowed) {
|
|
292
|
+
throw new Error(`Rate limit exceeded. Try again after ${recipientCheck.retryAfter}ms`);
|
|
293
|
+
}
|
|
300
294
|
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
295
|
+
// Check actor rate limit
|
|
296
|
+
const actorCheck = await loops.checkActorRateLimit(ctx, {
|
|
297
|
+
actorId: "user123",
|
|
298
|
+
timeWindowMs: 60000, // 1 minute
|
|
299
|
+
maxEmails: 20,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Check global rate limit
|
|
303
|
+
const globalCheck = await loops.checkGlobalRateLimit(ctx, {
|
|
304
|
+
timeWindowMs: 60000,
|
|
305
|
+
maxEmails: 1000,
|
|
309
306
|
});
|
|
310
307
|
```
|
|
311
308
|
|
|
312
|
-
**
|
|
313
|
-
- Migrations component iterating over app tables
|
|
314
|
-
- Webhook handlers calling app logic
|
|
315
|
-
- Background job processors
|
|
309
|
+
**Example: Rate-limited email sending**
|
|
316
310
|
|
|
317
|
-
|
|
311
|
+
```typescript
|
|
312
|
+
export const sendTransactionalWithRateLimit = action({
|
|
313
|
+
args: {
|
|
314
|
+
transactionalId: v.string(),
|
|
315
|
+
email: v.string(),
|
|
316
|
+
actorId: v.optional(v.string()),
|
|
317
|
+
},
|
|
318
|
+
handler: async (ctx, args) => {
|
|
319
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
320
|
+
if (!identity) throw new Error("Unauthorized");
|
|
318
321
|
|
|
319
|
-
|
|
320
|
-
- Components can only read/write their own tables
|
|
321
|
-
- Use `v.id("tableName")` for component tables
|
|
322
|
-
- Use `v.string()` for IDs from other components/app
|
|
323
|
-
- Use function handles to grant table access across boundaries
|
|
322
|
+
const actorId = args.actorId ?? identity.subject;
|
|
324
323
|
|
|
325
|
-
|
|
324
|
+
// Check rate limit before sending
|
|
325
|
+
const rateLimitCheck = await loops.checkActorRateLimit(ctx, {
|
|
326
|
+
actorId,
|
|
327
|
+
timeWindowMs: 60000, // 1 minute
|
|
328
|
+
maxEmails: 10,
|
|
329
|
+
});
|
|
326
330
|
|
|
327
|
-
|
|
331
|
+
if (!rateLimitCheck.allowed) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
`Rate limit exceeded. Please try again after ${rateLimitCheck.retryAfter}ms.`
|
|
334
|
+
);
|
|
335
|
+
}
|
|
328
336
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
private component: UseApi<typeof api>,
|
|
334
|
-
private options?: { apiKey?: string }
|
|
335
|
-
) {
|
|
336
|
-
this.apiKey = options?.apiKey ?? process.env.MY_API_KEY;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
async doSomething(ctx: QueryCtx) {
|
|
340
|
-
return await ctx.runQuery(this.component.public.process, {
|
|
341
|
-
apiKey: this.apiKey,
|
|
342
|
-
auth: await ctx.auth.getUserIdentity(),
|
|
337
|
+
// Send email
|
|
338
|
+
return await loops.sendTransactional(ctx, {
|
|
339
|
+
...args,
|
|
340
|
+
actorId,
|
|
343
341
|
});
|
|
344
|
-
}
|
|
345
|
-
}
|
|
342
|
+
},
|
|
343
|
+
});
|
|
346
344
|
```
|
|
347
345
|
|
|
348
|
-
|
|
346
|
+
## Using the API Helper
|
|
349
347
|
|
|
350
|
-
|
|
348
|
+
The component also exports an `api()` helper for easier re-exporting:
|
|
351
349
|
|
|
352
350
|
```typescript
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
//
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
351
|
+
import { Loops } from "@devwithbobby/loops";
|
|
352
|
+
import { components } from "./_generated/api";
|
|
353
|
+
|
|
354
|
+
const loops = new Loops(components.loops);
|
|
355
|
+
|
|
356
|
+
// Export all functions at once
|
|
357
|
+
export const {
|
|
358
|
+
addContact,
|
|
359
|
+
updateContact,
|
|
360
|
+
sendTransactional,
|
|
361
|
+
sendEvent,
|
|
362
|
+
sendCampaign,
|
|
363
|
+
triggerLoop,
|
|
364
|
+
countContacts,
|
|
365
|
+
// ... all other functions
|
|
366
|
+
} = loops.api();
|
|
365
367
|
```
|
|
366
368
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
The built-in `.paginate()` doesn't work in components. Use [`convex-helpers` paginator](https://github.com/get-convex/convex-helpers) instead:
|
|
369
|
+
**⚠️ Security Warning:** The `api()` helper exports functions without authentication. Always wrap these functions with auth checks in production:
|
|
370
370
|
|
|
371
371
|
```typescript
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
export const listItems = query({
|
|
376
|
-
args: { paginationOpts: paginationOptsValidator },
|
|
372
|
+
export const addContact = action({
|
|
373
|
+
args: { email: v.string(), ... },
|
|
377
374
|
handler: async (ctx, args) => {
|
|
378
|
-
|
|
375
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
376
|
+
if (!identity) throw new Error("Unauthorized");
|
|
377
|
+
|
|
378
|
+
return await loops.addContact(ctx, args);
|
|
379
379
|
},
|
|
380
380
|
});
|
|
381
381
|
```
|
|
382
382
|
|
|
383
|
-
##
|
|
383
|
+
## Security Best Practices
|
|
384
384
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
```typescript
|
|
392
|
-
import { convexTest as baseConvexTest } from "convex-test";
|
|
393
|
-
import { Glob } from "bun";
|
|
394
|
-
|
|
395
|
-
const glob = new Glob("**/*.ts");
|
|
396
|
-
const modules: Record<string, string> = {};
|
|
397
|
-
|
|
398
|
-
for await (const file of glob.scan("./src/component")) {
|
|
399
|
-
if (!file.startsWith("_generated/")) {
|
|
400
|
-
modules[file.replace(/\.ts$/, ".js")] = await Bun.file(
|
|
401
|
-
`./src/component/${file}`
|
|
402
|
-
).text();
|
|
403
|
-
}
|
|
404
|
-
}
|
|
385
|
+
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
|
|
388
|
+
4. **Monitor for abuse** - Use spam detection queries to identify suspicious patterns
|
|
389
|
+
5. **Sanitize errors** - Don't expose sensitive error details to clients
|
|
405
390
|
|
|
406
|
-
|
|
407
|
-
```
|
|
391
|
+
See [SECURITY.md](./prds/SECURITY.md) for detailed security guidelines.
|
|
408
392
|
|
|
409
|
-
|
|
393
|
+
## Monitoring & Rate Limiting
|
|
410
394
|
|
|
411
|
-
|
|
412
|
-
import { test, expect } from "bun:test";
|
|
413
|
-
import { api } from "../../src/component/_generated/api";
|
|
414
|
-
import { convexTest } from "./setup.test";
|
|
415
|
-
|
|
416
|
-
test("greet returns greeting", async () => {
|
|
417
|
-
const t = convexTest();
|
|
418
|
-
const result = await t.query(api.lib.greet, { name: "Alice" });
|
|
419
|
-
expect(result).toBe("Hello, Alice!");
|
|
420
|
-
});
|
|
395
|
+
The component automatically logs all email operations for monitoring. Use the built-in queries to:
|
|
421
396
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
expect(result.subject).toBe("user123");
|
|
427
|
-
});
|
|
428
|
-
```
|
|
397
|
+
- Track email statistics
|
|
398
|
+
- Detect spam patterns
|
|
399
|
+
- Enforce rate limits
|
|
400
|
+
- Monitor for abuse
|
|
429
401
|
|
|
430
|
-
|
|
402
|
+
See [MONITORING.md](./prds/MONITORING.md) and [RATE_LIMITING.md](./prds/RATE_LIMITING.md) for detailed guides.
|
|
431
403
|
|
|
432
|
-
|
|
404
|
+
## Environment Variables
|
|
433
405
|
|
|
434
|
-
|
|
406
|
+
Set `LOOPS_API_KEY` in your Convex environment:
|
|
435
407
|
|
|
436
408
|
```bash
|
|
437
|
-
|
|
438
|
-
bun pack # or npm pack
|
|
409
|
+
npx convex env set LOOPS_API_KEY "your-api-key"
|
|
439
410
|
```
|
|
440
411
|
|
|
441
|
-
|
|
442
|
-
```bash
|
|
443
|
-
bun install ../path/to/component/your-component-0.1.0.tgz
|
|
444
|
-
```
|
|
412
|
+
See [ENV_SETUP.md](./prds/ENV_SETUP.md) for detailed setup instructions.
|
|
445
413
|
|
|
446
|
-
|
|
414
|
+
## Development
|
|
447
415
|
|
|
448
|
-
|
|
416
|
+
### Local Development
|
|
449
417
|
|
|
450
|
-
|
|
451
|
-
2. Run: `bun publish` (or `npm publish` if bun publish is not yet available)
|
|
418
|
+
To use this component in development with live reloading:
|
|
452
419
|
|
|
453
|
-
|
|
420
|
+
```bash
|
|
421
|
+
bun run dev:backend
|
|
422
|
+
```
|
|
454
423
|
|
|
455
|
-
|
|
424
|
+
This starts Convex dev with `--live-component-sources` enabled, allowing changes to be reflected immediately.
|
|
456
425
|
|
|
457
|
-
|
|
458
|
-
- Data tables
|
|
459
|
-
- Functions
|
|
460
|
-
- File storage
|
|
461
|
-
- Logs
|
|
462
|
-
- Scheduled functions
|
|
426
|
+
### Building
|
|
463
427
|
|
|
464
|
-
|
|
428
|
+
```bash
|
|
429
|
+
npm run build
|
|
430
|
+
```
|
|
465
431
|
|
|
466
|
-
|
|
467
|
-
2. **Database bandwidth**: Component functions count bandwidth separately
|
|
468
|
-
3. **Logging**: Component logs appear in dashboard and log streams
|
|
469
|
-
4. **Exports**: Snapshot exports include all component data
|
|
470
|
-
5. **Streaming exports**: Only include top-level app data (not components)
|
|
432
|
+
### Testing
|
|
471
433
|
|
|
472
|
-
|
|
434
|
+
```bash
|
|
435
|
+
npm test
|
|
436
|
+
```
|
|
473
437
|
|
|
474
|
-
|
|
475
|
-
- **Linter/Formatter**: Biome
|
|
476
|
-
- **Indentation**: Tabs
|
|
477
|
-
- **Quotes**: Double quotes
|
|
478
|
-
- **TypeScript**: Strict mode with extra checks
|
|
438
|
+
## Project Structure
|
|
479
439
|
|
|
480
|
-
|
|
440
|
+
```
|
|
441
|
+
src/
|
|
442
|
+
component/ # The Convex component
|
|
443
|
+
convex.config.ts # Component configuration
|
|
444
|
+
schema.ts # Database schema
|
|
445
|
+
lib.ts # Component functions
|
|
446
|
+
validators.ts # Zod validators
|
|
447
|
+
tables/ # Table definitions
|
|
448
|
+
|
|
449
|
+
client/ # Client library
|
|
450
|
+
index.ts # Loops client class
|
|
451
|
+
types.ts # TypeScript types
|
|
452
|
+
|
|
453
|
+
example/ # Example app
|
|
454
|
+
convex/
|
|
455
|
+
example.ts # Example usage
|
|
456
|
+
```
|
|
481
457
|
|
|
482
|
-
|
|
458
|
+
## API Coverage
|
|
483
459
|
|
|
484
|
-
|
|
460
|
+
This component implements the following Loops.so API endpoints:
|
|
485
461
|
|
|
486
|
-
-
|
|
487
|
-
-
|
|
488
|
-
-
|
|
489
|
-
-
|
|
462
|
+
- ✅ Create/Update Contact
|
|
463
|
+
- ✅ Delete Contact
|
|
464
|
+
- ✅ Find Contact
|
|
465
|
+
- ✅ Batch Create Contacts
|
|
466
|
+
- ✅ Unsubscribe/Resubscribe Contact
|
|
467
|
+
- ✅ Count Contacts (custom implementation)
|
|
468
|
+
- ✅ Send Transactional Email
|
|
469
|
+
- ✅ Send Event
|
|
470
|
+
- ✅ Send Campaign
|
|
471
|
+
- ✅ Trigger Loop
|
|
490
472
|
|
|
491
|
-
##
|
|
473
|
+
## Contributing
|
|
492
474
|
|
|
493
|
-
|
|
494
|
-
- **Test location**: Always place tests in `test/component/` (not `src/component/`)
|
|
495
|
-
- **Component name**: Change `"loopsComponent"` in convex.config.ts to your component name
|
|
496
|
-
- **Live reloading**: Enabled via `--live-component-sources` flag
|
|
497
|
-
- **Peer dependencies**: Component uses the app's Convex installation
|
|
475
|
+
Contributions are welcome! Please open an issue or submit a pull request.
|
|
498
476
|
|
|
499
|
-
##
|
|
477
|
+
## License
|
|
500
478
|
|
|
501
|
-
-
|
|
502
|
-
- **[SECURITY.md](./SECURITY.md)** - Security considerations and guidelines
|
|
503
|
-
- **[MONITORING.md](./MONITORING.md)** - Email monitoring and spam detection
|
|
504
|
-
- **[RATE_LIMITING.md](./RATE_LIMITING.md)** - Rate limiting implementation guide
|
|
479
|
+
Apache-2.0
|
|
505
480
|
|
|
506
481
|
## Resources
|
|
507
482
|
|
|
483
|
+
- [Loops.so Documentation](https://loops.so/docs)
|
|
508
484
|
- [Convex Components Documentation](https://www.convex.dev/components)
|
|
509
|
-
- [Component Authoring Guide](https://docs.convex.dev/components/authoring)
|
|
510
485
|
- [Convex Environment Variables](https://docs.convex.dev/production/environment-variables)
|
|
511
|
-
- [convex-test](https://github.com/get-convex/convex-test)
|
|
512
|
-
- [convex-helpers](https://github.com/get-convex/convex-helpers)
|
|
513
|
-
- [Bun Documentation](https://bun.sh/docs)
|
|
514
|
-
|
|
515
|
-
## License
|
|
516
|
-
|
|
517
|
-
MIT
|