@devwithbobby/loops 0.1.0 → 0.1.1
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/package.json +2 -2
- package/.changeset/README.md +0 -8
- package/.changeset/config.json +0 -14
- 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/prds/MONITORING.md
DELETED
|
@@ -1,301 +0,0 @@
|
|
|
1
|
-
# Email Monitoring & Spam Detection
|
|
2
|
-
|
|
3
|
-
The Loops component includes built-in monitoring and spam detection capabilities to help you track email operations and identify unusual activity patterns.
|
|
4
|
-
|
|
5
|
-
## Features
|
|
6
|
-
|
|
7
|
-
1. **Automatic Operation Logging** - All email operations are automatically logged
|
|
8
|
-
2. **Spam Pattern Detection** - Queries to detect suspicious sending patterns
|
|
9
|
-
3. **Email Statistics** - Get comprehensive stats about email operations
|
|
10
|
-
4. **Rapid-Fire Detection** - Identify burst email sending patterns
|
|
11
|
-
|
|
12
|
-
## Automatic Logging
|
|
13
|
-
|
|
14
|
-
All email-sending operations are automatically logged to the `emailOperations` table:
|
|
15
|
-
|
|
16
|
-
- `sendTransactional` - Transactional emails
|
|
17
|
-
- `sendEvent` - Event-triggered emails
|
|
18
|
-
- `sendCampaign` - Campaign emails
|
|
19
|
-
- `triggerLoop` - Loop-triggered emails
|
|
20
|
-
|
|
21
|
-
Each operation records:
|
|
22
|
-
- Operation type
|
|
23
|
-
- Recipient email
|
|
24
|
-
- Actor ID (optional - set by app layer)
|
|
25
|
-
- Timestamp
|
|
26
|
-
- Success/failure status
|
|
27
|
-
- Related IDs (transactionalId, campaignId, loopId, eventName)
|
|
28
|
-
- Message ID (if available)
|
|
29
|
-
|
|
30
|
-
## Spam Detection Queries
|
|
31
|
-
|
|
32
|
-
### 1. Detect Recipient Spam
|
|
33
|
-
|
|
34
|
-
Detect emails sent to the same recipient too frequently:
|
|
35
|
-
|
|
36
|
-
```typescript
|
|
37
|
-
const suspicious = await ctx.runQuery(
|
|
38
|
-
components.loopsComponent.lib.detectRecipientSpam,
|
|
39
|
-
{
|
|
40
|
-
timeWindowMs: 3600000, // 1 hour (default)
|
|
41
|
-
maxEmailsPerRecipient: 10, // Default: 10 emails per hour
|
|
42
|
-
}
|
|
43
|
-
);
|
|
44
|
-
|
|
45
|
-
// Returns: [{ email: "user@example.com", count: 15, timeWindowMs: 3600000 }]
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
### 2. Detect Actor Spam
|
|
49
|
-
|
|
50
|
-
Detect emails sent by the same actor/user too frequently:
|
|
51
|
-
|
|
52
|
-
```typescript
|
|
53
|
-
const suspicious = await ctx.runQuery(
|
|
54
|
-
components.loopsComponent.lib.detectActorSpam,
|
|
55
|
-
{
|
|
56
|
-
timeWindowMs: 3600000, // 1 hour (default)
|
|
57
|
-
maxEmailsPerActor: 100, // Default: 100 emails per hour
|
|
58
|
-
}
|
|
59
|
-
);
|
|
60
|
-
|
|
61
|
-
// Returns: [{ actorId: "user-123", count: 150, timeWindowMs: 3600000 }]
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
**Note:** Actor ID must be set when sending emails. The app layer should pass it:
|
|
65
|
-
|
|
66
|
-
```typescript
|
|
67
|
-
// In your app's wrapper function
|
|
68
|
-
export const sendTransactional = action({
|
|
69
|
-
handler: async (ctx, args) => {
|
|
70
|
-
const identity = await ctx.auth.getUserIdentity();
|
|
71
|
-
// Pass actorId through metadata or extend the component
|
|
72
|
-
return await loops.sendTransactional(ctx, args);
|
|
73
|
-
},
|
|
74
|
-
});
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
### 3. Get Email Statistics
|
|
78
|
-
|
|
79
|
-
Get comprehensive statistics about email operations:
|
|
80
|
-
|
|
81
|
-
```typescript
|
|
82
|
-
const stats = await ctx.runQuery(
|
|
83
|
-
components.loopsComponent.lib.getEmailStats,
|
|
84
|
-
{
|
|
85
|
-
timeWindowMs: 86400000, // 24 hours (default)
|
|
86
|
-
}
|
|
87
|
-
);
|
|
88
|
-
|
|
89
|
-
// Returns:
|
|
90
|
-
// {
|
|
91
|
-
// totalOperations: 1250,
|
|
92
|
-
// successfulOperations: 1200,
|
|
93
|
-
// failedOperations: 50,
|
|
94
|
-
// operationsByType: {
|
|
95
|
-
// transactional: 800,
|
|
96
|
-
// event: 300,
|
|
97
|
-
// campaign: 100,
|
|
98
|
-
// loop: 50,
|
|
99
|
-
// },
|
|
100
|
-
// uniqueRecipients: 450,
|
|
101
|
-
// uniqueActors: 12,
|
|
102
|
-
// }
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
### 4. Detect Rapid-Fire Patterns
|
|
106
|
-
|
|
107
|
-
Detect burst email sending (multiple emails sent in quick succession):
|
|
108
|
-
|
|
109
|
-
```typescript
|
|
110
|
-
const patterns = await ctx.runQuery(
|
|
111
|
-
components.loopsComponent.lib.detectRapidFirePatterns,
|
|
112
|
-
{
|
|
113
|
-
timeWindowMs: 60000, // 1 minute (default)
|
|
114
|
-
minEmailsInWindow: 5, // Default: 5 emails in 1 minute
|
|
115
|
-
}
|
|
116
|
-
);
|
|
117
|
-
|
|
118
|
-
// Returns: [
|
|
119
|
-
// {
|
|
120
|
-
// email: "user@example.com",
|
|
121
|
-
// count: 8,
|
|
122
|
-
// timeWindowMs: 60000,
|
|
123
|
-
// firstTimestamp: 1234567890,
|
|
124
|
-
// lastTimestamp: 1234567950,
|
|
125
|
-
// },
|
|
126
|
-
// ...
|
|
127
|
-
// ]
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
## Usage Examples
|
|
131
|
-
|
|
132
|
-
### Monitor Email Operations in Real-Time
|
|
133
|
-
|
|
134
|
-
```typescript
|
|
135
|
-
// In your app
|
|
136
|
-
export const checkEmailHealth = query({
|
|
137
|
-
handler: async (ctx) => {
|
|
138
|
-
// Get stats for last hour
|
|
139
|
-
const stats = await ctx.runQuery(
|
|
140
|
-
components.loopsComponent.lib.getEmailStats,
|
|
141
|
-
{ timeWindowMs: 3600000 }
|
|
142
|
-
);
|
|
143
|
-
|
|
144
|
-
// Check for spam patterns
|
|
145
|
-
const recipientSpam = await ctx.runQuery(
|
|
146
|
-
components.loopsComponent.lib.detectRecipientSpam,
|
|
147
|
-
{ maxEmailsPerRecipient: 10 }
|
|
148
|
-
);
|
|
149
|
-
|
|
150
|
-
const actorSpam = await ctx.runQuery(
|
|
151
|
-
components.loopsComponent.lib.detectActorSpam,
|
|
152
|
-
{ maxEmailsPerActor: 100 }
|
|
153
|
-
);
|
|
154
|
-
|
|
155
|
-
return {
|
|
156
|
-
stats,
|
|
157
|
-
warnings: {
|
|
158
|
-
recipientSpam: recipientSpam.length > 0,
|
|
159
|
-
actorSpam: actorSpam.length > 0,
|
|
160
|
-
failureRate: stats.failedOperations / stats.totalOperations,
|
|
161
|
-
},
|
|
162
|
-
suspiciousRecipients: recipientSpam,
|
|
163
|
-
suspiciousActors: actorSpam,
|
|
164
|
-
};
|
|
165
|
-
},
|
|
166
|
-
});
|
|
167
|
-
```
|
|
168
|
-
|
|
169
|
-
### Automatic Rate Limiting Based on Monitoring
|
|
170
|
-
|
|
171
|
-
```typescript
|
|
172
|
-
export const sendTransactional = action({
|
|
173
|
-
args: { email: v.string(), transactionalId: v.string() },
|
|
174
|
-
handler: async (ctx, args) => {
|
|
175
|
-
const identity = await ctx.auth.getUserIdentity();
|
|
176
|
-
if (!identity) throw new Error("Unauthorized");
|
|
177
|
-
|
|
178
|
-
// Check for spam patterns before sending
|
|
179
|
-
const recentRecipientEmails = await ctx.runQuery(
|
|
180
|
-
components.loopsComponent.lib.detectRecipientSpam,
|
|
181
|
-
{
|
|
182
|
-
timeWindowMs: 3600000, // 1 hour
|
|
183
|
-
maxEmailsPerRecipient: 10,
|
|
184
|
-
}
|
|
185
|
-
);
|
|
186
|
-
|
|
187
|
-
const isSuspicious = recentRecipientEmails.some(
|
|
188
|
-
(r) => r.email === args.email && r.count >= 10
|
|
189
|
-
);
|
|
190
|
-
|
|
191
|
-
if (isSuspicious) {
|
|
192
|
-
console.warn(`Suspicious activity detected for ${args.email}`);
|
|
193
|
-
// Log for review, but still allow (or block if needed)
|
|
194
|
-
// throw new Error("Rate limit exceeded");
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
return await loops.sendTransactional(ctx, args);
|
|
198
|
-
},
|
|
199
|
-
});
|
|
200
|
-
```
|
|
201
|
-
|
|
202
|
-
### Scheduled Spam Monitoring
|
|
203
|
-
|
|
204
|
-
Use Convex scheduled functions to periodically check for spam:
|
|
205
|
-
|
|
206
|
-
```typescript
|
|
207
|
-
// In your app's convex/scheduled.ts
|
|
208
|
-
import { cronJobs } from "convex/server";
|
|
209
|
-
import { internal } from "./_generated/api";
|
|
210
|
-
|
|
211
|
-
const crons = cronJobs();
|
|
212
|
-
|
|
213
|
-
// Check for spam every hour
|
|
214
|
-
crons.hourly(
|
|
215
|
-
"checkSpam",
|
|
216
|
-
{
|
|
217
|
-
hourUTC: 0, // Run at start of each hour
|
|
218
|
-
},
|
|
219
|
-
internal.monitoring.checkForSpam,
|
|
220
|
-
);
|
|
221
|
-
|
|
222
|
-
// In convex/monitoring.ts
|
|
223
|
-
export const checkForSpam = internalAction({
|
|
224
|
-
handler: async (ctx) => {
|
|
225
|
-
const recipientSpam = await ctx.runQuery(
|
|
226
|
-
components.loopsComponent.lib.detectRecipientSpam,
|
|
227
|
-
{ maxEmailsPerRecipient: 10 }
|
|
228
|
-
);
|
|
229
|
-
|
|
230
|
-
const actorSpam = await ctx.runQuery(
|
|
231
|
-
components.loopsComponent.lib.detectActorSpam,
|
|
232
|
-
{ maxEmailsPerActor: 100 }
|
|
233
|
-
);
|
|
234
|
-
|
|
235
|
-
if (recipientSpam.length > 0 || actorSpam.length > 0) {
|
|
236
|
-
// Send alert (email, Slack, etc.)
|
|
237
|
-
console.error("Spam patterns detected:", {
|
|
238
|
-
recipientSpam,
|
|
239
|
-
actorSpam,
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
// Could send alert email via Loops itself!
|
|
243
|
-
await ctx.runAction(components.loopsComponent.lib.sendTransactional, {
|
|
244
|
-
apiKey: process.env.LOOPS_API_KEY!,
|
|
245
|
-
transactionalId: "alert-id",
|
|
246
|
-
email: "admin@example.com",
|
|
247
|
-
dataVariables: {
|
|
248
|
-
recipientSpamCount: recipientSpam.length,
|
|
249
|
-
actorSpamCount: actorSpam.length,
|
|
250
|
-
},
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
|
-
},
|
|
254
|
-
});
|
|
255
|
-
```
|
|
256
|
-
|
|
257
|
-
## Using the Client Library
|
|
258
|
-
|
|
259
|
-
If you're using the `Loops` client class:
|
|
260
|
-
|
|
261
|
-
```typescript
|
|
262
|
-
import { Loops } from "robertalv/loops-component";
|
|
263
|
-
|
|
264
|
-
const loops = new Loops(components.loopsComponent);
|
|
265
|
-
|
|
266
|
-
// In a query
|
|
267
|
-
const stats = await loops.getEmailStats(ctx, { timeWindowMs: 3600000 });
|
|
268
|
-
const spam = await loops.detectRecipientSpam(ctx, { maxEmailsPerRecipient: 10 });
|
|
269
|
-
```
|
|
270
|
-
|
|
271
|
-
Or export from `loops.api()`:
|
|
272
|
-
|
|
273
|
-
```typescript
|
|
274
|
-
export const {
|
|
275
|
-
detectRecipientSpam,
|
|
276
|
-
detectActorSpam,
|
|
277
|
-
getEmailStats,
|
|
278
|
-
detectRapidFirePatterns,
|
|
279
|
-
} = loops.api();
|
|
280
|
-
```
|
|
281
|
-
|
|
282
|
-
## Best Practices
|
|
283
|
-
|
|
284
|
-
1. **Set Actor IDs** - Pass actor IDs from your auth system to track who's sending emails
|
|
285
|
-
2. **Regular Monitoring** - Set up scheduled checks to monitor for spam patterns
|
|
286
|
-
3. **Customize Thresholds** - Adjust thresholds based on your email volume and use case
|
|
287
|
-
4. **Log Actions** - When spam is detected, log it for audit purposes
|
|
288
|
-
5. **Rate Limiting** - Use monitoring results to implement dynamic rate limiting
|
|
289
|
-
6. **Alerting** - Set up alerts when spam patterns are detected
|
|
290
|
-
|
|
291
|
-
## Configuration Recommendations
|
|
292
|
-
|
|
293
|
-
Based on typical email sending patterns:
|
|
294
|
-
|
|
295
|
-
- **Transactional Emails**: 10-20 per recipient per hour is reasonable
|
|
296
|
-
- **Event-Triggered**: 5-10 per recipient per hour
|
|
297
|
-
- **Campaigns**: Usually sent in bulk, less frequent
|
|
298
|
-
- **Rapid-Fire Detection**: 5+ emails in 1 minute is suspicious
|
|
299
|
-
|
|
300
|
-
Adjust these based on your specific use case and email volume.
|
|
301
|
-
|
package/prds/RATE_LIMITING.md
DELETED
|
@@ -1,412 +0,0 @@
|
|
|
1
|
-
# Rate Limiting for Email Sending
|
|
2
|
-
|
|
3
|
-
The Loops component provides built-in rate limiting capabilities to prevent email abuse and ensure compliance with email service provider limits.
|
|
4
|
-
|
|
5
|
-
## Features
|
|
6
|
-
|
|
7
|
-
1. **Recipient-based Rate Limiting** - Limit emails per recipient
|
|
8
|
-
2. **Actor-based Rate Limiting** - Limit emails per user/actor
|
|
9
|
-
3. **Global Rate Limiting** - Limit total emails across the system
|
|
10
|
-
4. **Automatic Rate Limit Checks** - Based on logged email operations
|
|
11
|
-
5. **Rate Limit Status Queries** - Check rate limit status before sending
|
|
12
|
-
|
|
13
|
-
## Rate Limiting Functions
|
|
14
|
-
|
|
15
|
-
### 1. Check Recipient Rate Limit
|
|
16
|
-
|
|
17
|
-
Check if an email can be sent to a specific recipient:
|
|
18
|
-
|
|
19
|
-
```typescript
|
|
20
|
-
const rateLimitCheck = await ctx.runQuery(
|
|
21
|
-
components.loopsComponent.lib.checkRecipientRateLimit,
|
|
22
|
-
{
|
|
23
|
-
email: "user@example.com",
|
|
24
|
-
timeWindowMs: 3600000, // 1 hour
|
|
25
|
-
maxEmails: 10, // Max 10 emails per hour to this recipient
|
|
26
|
-
}
|
|
27
|
-
);
|
|
28
|
-
|
|
29
|
-
if (!rateLimitCheck.allowed) {
|
|
30
|
-
throw new Error(
|
|
31
|
-
`Rate limit exceeded. ${rateLimitCheck.count}/${rateLimitCheck.limit} emails sent in the last hour. Retry after ${Math.ceil(rateLimitCheck.retryAfter! / 1000)} seconds.`
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
**Response:**
|
|
37
|
-
```typescript
|
|
38
|
-
{
|
|
39
|
-
allowed: boolean; // Whether sending is allowed
|
|
40
|
-
count: number; // Current count in time window
|
|
41
|
-
limit: number; // Maximum allowed
|
|
42
|
-
timeWindowMs: number; // Time window size
|
|
43
|
-
retryAfter?: number; // Milliseconds until oldest email expires (if limited)
|
|
44
|
-
}
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
### 2. Check Actor Rate Limit
|
|
48
|
-
|
|
49
|
-
Check if a specific user/actor can send more emails:
|
|
50
|
-
|
|
51
|
-
```typescript
|
|
52
|
-
const rateLimitCheck = await ctx.runQuery(
|
|
53
|
-
components.loopsComponent.lib.checkActorRateLimit,
|
|
54
|
-
{
|
|
55
|
-
actorId: "user-123", // User ID or actor identifier
|
|
56
|
-
timeWindowMs: 3600000, // 1 hour
|
|
57
|
-
maxEmails: 100, // Max 100 emails per hour from this actor
|
|
58
|
-
}
|
|
59
|
-
);
|
|
60
|
-
|
|
61
|
-
if (!rateLimitCheck.allowed) {
|
|
62
|
-
throw new Error(`Rate limit exceeded for actor.`);
|
|
63
|
-
}
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
**Note:** Actor ID must be set when logging email operations. The app layer should pass it through (see below).
|
|
67
|
-
|
|
68
|
-
### 3. Check Global Rate Limit
|
|
69
|
-
|
|
70
|
-
Check system-wide email sending rate:
|
|
71
|
-
|
|
72
|
-
```typescript
|
|
73
|
-
const rateLimitCheck = await ctx.runQuery(
|
|
74
|
-
components.loopsComponent.lib.checkGlobalRateLimit,
|
|
75
|
-
{
|
|
76
|
-
timeWindowMs: 3600000, // 1 hour
|
|
77
|
-
maxEmails: 10000, // Max 10,000 emails per hour system-wide
|
|
78
|
-
}
|
|
79
|
-
);
|
|
80
|
-
|
|
81
|
-
if (!rateLimitCheck.allowed) {
|
|
82
|
-
throw new Error(`Global rate limit exceeded.`);
|
|
83
|
-
}
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
## Usage Patterns
|
|
87
|
-
|
|
88
|
-
### Pattern 1: Rate Limit Check Before Sending
|
|
89
|
-
|
|
90
|
-
```typescript
|
|
91
|
-
export const sendTransactional = action({
|
|
92
|
-
args: {
|
|
93
|
-
email: v.string(),
|
|
94
|
-
transactionalId: v.string(),
|
|
95
|
-
},
|
|
96
|
-
handler: async (ctx, args) => {
|
|
97
|
-
const identity = await ctx.auth.getUserIdentity();
|
|
98
|
-
if (!identity) throw new Error("Unauthorized");
|
|
99
|
-
|
|
100
|
-
// Check recipient rate limit
|
|
101
|
-
const recipientCheck = await ctx.runQuery(
|
|
102
|
-
components.loopsComponent.lib.checkRecipientRateLimit,
|
|
103
|
-
{
|
|
104
|
-
email: args.email,
|
|
105
|
-
timeWindowMs: 3600000, // 1 hour
|
|
106
|
-
maxEmails: 10, // 10 emails per hour per recipient
|
|
107
|
-
}
|
|
108
|
-
);
|
|
109
|
-
|
|
110
|
-
if (!recipientCheck.allowed) {
|
|
111
|
-
const retrySeconds = Math.ceil((recipientCheck.retryAfter ?? 0) / 1000);
|
|
112
|
-
throw new Error(
|
|
113
|
-
`Rate limit exceeded. You can send emails to ${args.email} again in ${retrySeconds} seconds.`
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Check actor rate limit
|
|
118
|
-
const actorCheck = await ctx.runQuery(
|
|
119
|
-
components.loopsComponent.lib.checkActorRateLimit,
|
|
120
|
-
{
|
|
121
|
-
actorId: identity.subject,
|
|
122
|
-
timeWindowMs: 3600000, // 1 hour
|
|
123
|
-
maxEmails: 100, // 100 emails per hour per user
|
|
124
|
-
}
|
|
125
|
-
);
|
|
126
|
-
|
|
127
|
-
if (!actorCheck.allowed) {
|
|
128
|
-
throw new Error(`You've reached your email sending limit. Please try again later.`);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Send email if rate limits pass
|
|
132
|
-
return await loops.sendTransactional(ctx, {
|
|
133
|
-
email: args.email,
|
|
134
|
-
transactionalId: args.transactionalId,
|
|
135
|
-
});
|
|
136
|
-
},
|
|
137
|
-
});
|
|
138
|
-
```
|
|
139
|
-
|
|
140
|
-
### Pattern 2: Helper Function for Rate Limiting
|
|
141
|
-
|
|
142
|
-
Create a reusable helper function:
|
|
143
|
-
|
|
144
|
-
```typescript
|
|
145
|
-
// In your app's convex/rateLimits.ts
|
|
146
|
-
import { components } from "./_generated/api";
|
|
147
|
-
|
|
148
|
-
export async function enforceEmailRateLimits(
|
|
149
|
-
ctx: ActionCtx,
|
|
150
|
-
options: {
|
|
151
|
-
email: string;
|
|
152
|
-
actorId: string;
|
|
153
|
-
}
|
|
154
|
-
) {
|
|
155
|
-
// Check recipient limit (10 per hour)
|
|
156
|
-
const recipientCheck = await ctx.runQuery(
|
|
157
|
-
components.loopsComponent.lib.checkRecipientRateLimit,
|
|
158
|
-
{
|
|
159
|
-
email: options.email,
|
|
160
|
-
timeWindowMs: 3600000,
|
|
161
|
-
maxEmails: 10,
|
|
162
|
-
}
|
|
163
|
-
);
|
|
164
|
-
|
|
165
|
-
if (!recipientCheck.allowed) {
|
|
166
|
-
const retrySeconds = Math.ceil((recipientCheck.retryAfter ?? 0) / 1000);
|
|
167
|
-
throw new Error(
|
|
168
|
-
`Rate limit exceeded. Retry after ${retrySeconds} seconds.`
|
|
169
|
-
);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// Check actor limit (100 per hour)
|
|
173
|
-
const actorCheck = await ctx.runQuery(
|
|
174
|
-
components.loopsComponent.lib.checkActorRateLimit,
|
|
175
|
-
{
|
|
176
|
-
actorId: options.actorId,
|
|
177
|
-
timeWindowMs: 3600000,
|
|
178
|
-
maxEmails: 100,
|
|
179
|
-
}
|
|
180
|
-
);
|
|
181
|
-
|
|
182
|
-
if (!actorCheck.allowed) {
|
|
183
|
-
throw new Error(`Email sending limit reached. Please try again later.`);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Optional: Check global limit
|
|
187
|
-
const globalCheck = await ctx.runQuery(
|
|
188
|
-
components.loopsComponent.lib.checkGlobalRateLimit,
|
|
189
|
-
{
|
|
190
|
-
timeWindowMs: 3600000,
|
|
191
|
-
maxEmails: 10000, // 10k per hour system-wide
|
|
192
|
-
}
|
|
193
|
-
);
|
|
194
|
-
|
|
195
|
-
if (!globalCheck.allowed) {
|
|
196
|
-
throw new Error(`System email limit reached. Please try again later.`);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// Then use it:
|
|
201
|
-
export const sendTransactional = action({
|
|
202
|
-
handler: async (ctx, args) => {
|
|
203
|
-
const identity = await ctx.auth.getUserIdentity();
|
|
204
|
-
if (!identity) throw new Error("Unauthorized");
|
|
205
|
-
|
|
206
|
-
await enforceEmailRateLimits(ctx, {
|
|
207
|
-
email: args.email,
|
|
208
|
-
actorId: identity.subject,
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
return await loops.sendTransactional(ctx, args);
|
|
212
|
-
},
|
|
213
|
-
});
|
|
214
|
-
```
|
|
215
|
-
|
|
216
|
-
### Pattern 3: Different Limits for Different Operations
|
|
217
|
-
|
|
218
|
-
```typescript
|
|
219
|
-
const RATE_LIMITS = {
|
|
220
|
-
transactional: {
|
|
221
|
-
recipient: { timeWindowMs: 3600000, maxEmails: 10 }, // 10/hour
|
|
222
|
-
actor: { timeWindowMs: 3600000, maxEmails: 100 }, // 100/hour
|
|
223
|
-
},
|
|
224
|
-
campaign: {
|
|
225
|
-
actor: { timeWindowMs: 3600000, maxEmails: 5 }, // 5/hour (more restrictive)
|
|
226
|
-
},
|
|
227
|
-
event: {
|
|
228
|
-
recipient: { timeWindowMs: 3600000, maxEmails: 20 }, // 20/hour
|
|
229
|
-
actor: { timeWindowMs: 3600000, maxEmails: 200 }, // 200/hour
|
|
230
|
-
},
|
|
231
|
-
};
|
|
232
|
-
|
|
233
|
-
export const sendTransactional = action({
|
|
234
|
-
handler: async (ctx, args) => {
|
|
235
|
-
const identity = await ctx.auth.getUserIdentity();
|
|
236
|
-
if (!identity) throw new Error("Unauthorized");
|
|
237
|
-
|
|
238
|
-
const limits = RATE_LIMITS.transactional;
|
|
239
|
-
|
|
240
|
-
// Check limits
|
|
241
|
-
const recipientCheck = await ctx.runQuery(
|
|
242
|
-
components.loopsComponent.lib.checkRecipientRateLimit,
|
|
243
|
-
{
|
|
244
|
-
email: args.email,
|
|
245
|
-
...limits.recipient,
|
|
246
|
-
}
|
|
247
|
-
);
|
|
248
|
-
|
|
249
|
-
if (!recipientCheck.allowed) {
|
|
250
|
-
throw new Error("Recipient rate limit exceeded");
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
const actorCheck = await ctx.runQuery(
|
|
254
|
-
components.loopsComponent.lib.checkActorRateLimit,
|
|
255
|
-
{
|
|
256
|
-
actorId: identity.subject,
|
|
257
|
-
...limits.actor,
|
|
258
|
-
}
|
|
259
|
-
);
|
|
260
|
-
|
|
261
|
-
if (!actorCheck.allowed) {
|
|
262
|
-
throw new Error("Actor rate limit exceeded");
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
return await loops.sendTransactional(ctx, args);
|
|
266
|
-
},
|
|
267
|
-
});
|
|
268
|
-
```
|
|
269
|
-
|
|
270
|
-
## Setting Actor IDs
|
|
271
|
-
|
|
272
|
-
To use actor-based rate limiting, you need to ensure actor IDs are logged with email operations. Currently, the component logs operations automatically, but actor IDs must be passed from the app layer.
|
|
273
|
-
|
|
274
|
-
**Future Enhancement:** The component could accept an optional `actorId` parameter in email-sending functions. For now, you can track actor IDs by:
|
|
275
|
-
|
|
276
|
-
1. Using the `userId` field in contacts (which gets logged in some operations)
|
|
277
|
-
2. Extending the component to accept actor IDs
|
|
278
|
-
3. Creating wrapper functions that include actor ID in metadata
|
|
279
|
-
|
|
280
|
-
## Using the Client Library
|
|
281
|
-
|
|
282
|
-
```typescript
|
|
283
|
-
import { Loops } from "robertalv/loops-component";
|
|
284
|
-
|
|
285
|
-
const loops = new Loops(components.loopsComponent);
|
|
286
|
-
|
|
287
|
-
// In an action
|
|
288
|
-
export const sendTransactional = action({
|
|
289
|
-
handler: async (ctx, args) => {
|
|
290
|
-
// Check rate limit
|
|
291
|
-
const check = await loops.checkRecipientRateLimit(ctx, {
|
|
292
|
-
email: args.email,
|
|
293
|
-
timeWindowMs: 3600000,
|
|
294
|
-
maxEmails: 10,
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
if (!check.allowed) {
|
|
298
|
-
throw new Error("Rate limit exceeded");
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// Send email
|
|
302
|
-
return await loops.sendTransactional(ctx, args);
|
|
303
|
-
},
|
|
304
|
-
});
|
|
305
|
-
```
|
|
306
|
-
|
|
307
|
-
Or export rate limit functions:
|
|
308
|
-
|
|
309
|
-
```typescript
|
|
310
|
-
export const {
|
|
311
|
-
checkRecipientRateLimit,
|
|
312
|
-
checkActorRateLimit,
|
|
313
|
-
checkGlobalRateLimit,
|
|
314
|
-
} = loops.api();
|
|
315
|
-
```
|
|
316
|
-
|
|
317
|
-
## Recommended Rate Limits
|
|
318
|
-
|
|
319
|
-
Based on email best practices:
|
|
320
|
-
|
|
321
|
-
### Per Recipient
|
|
322
|
-
- **Transactional emails**: 10-20 per hour
|
|
323
|
-
- **Event-triggered emails**: 5-10 per hour
|
|
324
|
-
- **Marketing emails**: 1-3 per day
|
|
325
|
-
|
|
326
|
-
### Per Actor/User
|
|
327
|
-
- **Regular users**: 50-100 emails per hour
|
|
328
|
-
- **Power users**: 200-500 emails per hour
|
|
329
|
-
- **System/automated**: Higher limits with monitoring
|
|
330
|
-
|
|
331
|
-
### Global
|
|
332
|
-
- Based on your email service provider limits
|
|
333
|
-
- Typical: 10,000-100,000 emails per hour
|
|
334
|
-
- Monitor and adjust based on your usage
|
|
335
|
-
|
|
336
|
-
## Rate Limiting with convex-helpers
|
|
337
|
-
|
|
338
|
-
You can also combine component rate limiting with `convex-helpers` rate limiting:
|
|
339
|
-
|
|
340
|
-
```typescript
|
|
341
|
-
import { rateLimit } from "convex-helpers/server/rateLimit";
|
|
342
|
-
|
|
343
|
-
export const sendTransactional = action({
|
|
344
|
-
handler: async (ctx, args) => {
|
|
345
|
-
// Generic rate limiting (per user)
|
|
346
|
-
await rateLimit(ctx, { maxOps: 10, period: 60000 }); // 10 per minute
|
|
347
|
-
|
|
348
|
-
// Email-specific rate limiting (per recipient)
|
|
349
|
-
const emailCheck = await ctx.runQuery(
|
|
350
|
-
components.loopsComponent.lib.checkRecipientRateLimit,
|
|
351
|
-
{
|
|
352
|
-
email: args.email,
|
|
353
|
-
timeWindowMs: 3600000,
|
|
354
|
-
maxEmails: 10,
|
|
355
|
-
}
|
|
356
|
-
);
|
|
357
|
-
|
|
358
|
-
if (!emailCheck.allowed) {
|
|
359
|
-
throw new Error("Email rate limit exceeded");
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
return await loops.sendTransactional(ctx, args);
|
|
363
|
-
},
|
|
364
|
-
});
|
|
365
|
-
```
|
|
366
|
-
|
|
367
|
-
## Best Practices
|
|
368
|
-
|
|
369
|
-
1. **Always check rate limits before sending** - Prevents unnecessary API calls
|
|
370
|
-
2. **Use multiple layers** - Combine recipient, actor, and global limits
|
|
371
|
-
3. **Provide clear error messages** - Tell users when they can retry
|
|
372
|
-
4. **Monitor rate limit hits** - Log when limits are exceeded for analysis
|
|
373
|
-
5. **Adjust limits based on usage** - Start conservative and increase as needed
|
|
374
|
-
6. **Consider different limits** - Transactional vs marketing emails may have different limits
|
|
375
|
-
7. **Use retryAfter** - Provide users with retry timing information
|
|
376
|
-
|
|
377
|
-
## Integration with Monitoring
|
|
378
|
-
|
|
379
|
-
Rate limiting works seamlessly with the monitoring system:
|
|
380
|
-
|
|
381
|
-
```typescript
|
|
382
|
-
export const sendTransactional = action({
|
|
383
|
-
handler: async (ctx, args) => {
|
|
384
|
-
// Check rate limit
|
|
385
|
-
const check = await ctx.runQuery(
|
|
386
|
-
components.loopsComponent.lib.checkRecipientRateLimit,
|
|
387
|
-
{ email: args.email, timeWindowMs: 3600000, maxEmails: 10 }
|
|
388
|
-
);
|
|
389
|
-
|
|
390
|
-
if (!check.allowed) {
|
|
391
|
-
// Log rate limit hit for monitoring
|
|
392
|
-
console.warn(`Rate limit exceeded for ${args.email}`, check);
|
|
393
|
-
|
|
394
|
-
// Check if it's a spam pattern
|
|
395
|
-
const spamCheck = await ctx.runQuery(
|
|
396
|
-
components.loopsComponent.lib.detectRecipientSpam,
|
|
397
|
-
{ maxEmailsPerRecipient: 10 }
|
|
398
|
-
);
|
|
399
|
-
|
|
400
|
-
if (spamCheck.some((s) => s.email === args.email)) {
|
|
401
|
-
// Alert security team
|
|
402
|
-
console.error(`Potential spam detected for ${args.email}`);
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
throw new Error("Rate limit exceeded");
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
return await loops.sendTransactional(ctx, args);
|
|
409
|
-
},
|
|
410
|
-
});
|
|
411
|
-
```
|
|
412
|
-
|