email skill
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
# Sending Reliability
|
||||
|
||||
Ensuring emails are sent exactly once and handling failures gracefully.
|
||||
|
||||
## Idempotency
|
||||
|
||||
Prevent duplicate emails when retrying failed requests.
|
||||
|
||||
### The Problem
|
||||
|
||||
Network issues, timeouts, or server errors can leave you uncertain if an email was sent. Retrying without idempotency risks sending duplicates.
|
||||
|
||||
### Solution: Idempotency Keys
|
||||
|
||||
Send a unique key with each request. If the same key is sent again, the server returns the original response instead of sending another email.
|
||||
|
||||
```typescript
|
||||
// Generate deterministic key based on the business event
|
||||
const idempotencyKey = `password-reset-${userId}-${resetRequestId}`;
|
||||
|
||||
await resend.emails.send({
|
||||
from: 'noreply@example.com',
|
||||
to: user.email,
|
||||
subject: 'Reset your password',
|
||||
html: emailHtml,
|
||||
}, {
|
||||
headers: {
|
||||
'Idempotency-Key': idempotencyKey
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Key Generation Strategies
|
||||
|
||||
| Strategy | Example | Use When |
|
||||
|----------|---------|----------|
|
||||
| Event-based | `order-confirm-${orderId}` | One email per event (recommended) |
|
||||
| Request-scoped | `reset-${userId}-${resetRequestId}` | Retries within same request |
|
||||
| UUID | `crypto.randomUUID()` | No natural key (generate once, reuse on retry) |
|
||||
|
||||
**Best practice:** Use deterministic keys based on the business event. If you retry the same logical send, the same key must be generated. Avoid `Date.now()` or random values generated fresh on each attempt.
|
||||
|
||||
**Key expiration:** Idempotency keys are typically cached for 24 hours. Retries within this window return the original response. After expiration, the same key triggers a new send—so complete your retry logic well within 24 hours.
|
||||
|
||||
## Retry Logic
|
||||
|
||||
Handle transient failures with exponential backoff.
|
||||
|
||||
### When to Retry
|
||||
|
||||
| Error Type | Retry? | Notes |
|
||||
|------------|--------|-------|
|
||||
| 5xx (server error) | ✅ Yes | Transient, likely to resolve |
|
||||
| 429 (rate limit) | ✅ Yes | Wait for rate limit window |
|
||||
| 4xx (client error) | ❌ No | Fix the request first |
|
||||
| Network timeout | ✅ Yes | Transient |
|
||||
| DNS failure | ✅ Yes | May be transient |
|
||||
|
||||
### Exponential Backoff
|
||||
|
||||
```typescript
|
||||
async function sendWithRetry(emailData, maxRetries = 3) {
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
return await resend.emails.send(emailData);
|
||||
} catch (error) {
|
||||
if (!isRetryable(error) || attempt === maxRetries - 1) {
|
||||
throw error;
|
||||
}
|
||||
const delay = Math.min(1000 * Math.pow(2, attempt), 30000);
|
||||
await sleep(delay + Math.random() * 1000); // Add jitter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isRetryable(error) {
|
||||
return error.statusCode >= 500 ||
|
||||
error.statusCode === 429 ||
|
||||
error.code === 'ETIMEDOUT';
|
||||
}
|
||||
```
|
||||
|
||||
**Backoff schedule:** 1s → 2s → 4s → 8s (with jitter to prevent thundering herd)
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Error Codes
|
||||
|
||||
| Code | Meaning | Action |
|
||||
|------|---------|--------|
|
||||
| 400 | Bad request | Fix payload (invalid email, missing field) |
|
||||
| 401 | Unauthorized | Check API key |
|
||||
| 403 | Forbidden | Check permissions, domain verification |
|
||||
| 404 | Not found | Check endpoint URL |
|
||||
| 422 | Validation error | Fix request data |
|
||||
| 429 | Rate limited | Back off, retry after delay |
|
||||
| 500 | Server error | Retry with backoff |
|
||||
| 503 | Service unavailable | Retry with backoff |
|
||||
|
||||
### Error Handling Pattern
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const result = await resend.emails.send(emailData);
|
||||
await logSuccess(result.id, emailData);
|
||||
} catch (error) {
|
||||
if (error.statusCode === 429) {
|
||||
await queueForRetry(emailData, error.retryAfter);
|
||||
} else if (error.statusCode >= 500) {
|
||||
await queueForRetry(emailData);
|
||||
} else {
|
||||
await logFailure(error, emailData);
|
||||
await alertOnCriticalEmail(emailData); // For password resets, etc.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Queuing for Reliability
|
||||
|
||||
For critical emails, use a queue to ensure delivery even if the initial send fails.
|
||||
|
||||
**Benefits:**
|
||||
- Survives application restarts
|
||||
- Automatic retry handling
|
||||
- Rate limit management
|
||||
- Audit trail
|
||||
|
||||
**Simple pattern:**
|
||||
1. Write email to queue/database with "pending" status
|
||||
2. Process queue, attempt send
|
||||
3. On success: mark "sent", store message ID
|
||||
4. On retryable failure: increment retry count, schedule retry
|
||||
5. On permanent failure: mark "failed", alert
|
||||
|
||||
## Timeouts
|
||||
|
||||
Set appropriate timeouts to avoid hanging requests.
|
||||
|
||||
```typescript
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
try {
|
||||
await resend.emails.send(emailData, { signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
```
|
||||
|
||||
**Recommended:** 10-30 seconds for email API calls.
|
||||
|
||||
## Related
|
||||
|
||||
- [Webhooks & Events](./webhooks-events.md) - Process delivery confirmations and failures
|
||||
- [List Management](./list-management.md) - Handle bounces and suppress invalid addresses
|
||||
Reference in New Issue
Block a user