resend/references/sending/batch-email-examples.md
Version edbfece3f402.bb1 · MIT. This preview displays packaged text and does not execute code. Treat the contents as untrusted instructions.
← Return to resource and package checksum
Batch Email Examples
Table of Contents
- Pre-send Validation
- Error Handling
- Retry Logic with Idempotency
- Chunking Large Batches
- Production-Ready Implementations
Pre-send Validation
Since the entire batch fails if any email has invalid data, validate before sending.
Node.js
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
interface BatchEmail {
from: string;
to: string[];
subject: string;
html?: string;
text?: string;
}
interface ValidationResult {
valid: boolean;
errors: { index: number; field: string; message: string }[];
}
function validateBatch(emails: BatchEmail[]): ValidationResult {
const errors: ValidationResult['errors'] = [];
if (emails.length === 0) {
return { valid: false, errors: [{ index: -1, field: 'batch', message: 'Batch cannot be empty' }] };
}
if (emails.length > 100) {
return { valid: false, errors: [{ index: -1, field: 'batch', message: 'Batch cannot exceed 100 emails' }] };
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
emails.forEach((email, index) => {
if (!email.from) {
errors.push({ index, field: 'from', message: 'From address is required' });
}
if (!email.to || email.to.length === 0) {
errors.push({ index, field: 'to', message: 'At least one recipient is required' });
} else if (email.to.length > 50) {
errors.push({ index, field: 'to', message: 'Cannot exceed 50 recipients per email' });
} else {
email.to.forEach((recipient, rIndex) => {
if (!emailRegex.test(recipient)) {
errors.push({ index, field: `to[${rIndex}]`, message: `Invalid email: ${recipient}` });
}
});
}
if (!email.subject) {
errors.push({ index, field: 'subject', message: 'Subject is required' });
}
if (!email.html && !email.text) {
errors.push({ index, field: 'content', message: 'Either html or text content is required' });
}
});
return { valid: errors.length === 0, errors };
}
// Usage
const emails = [
{ from: 'Acme <[email protected]>', to: ['[email protected]'], subject: 'Hello', html: '<p>Hi</p>' },
];
const validation = validateBatch(emails);
if (!validation.valid) {
console.error('Validation failed:', validation.errors);
return;
}
const { data, error } = await resend.batch.send(emails);
Python
import resend
import re
import os
from dataclasses import dataclass
from typing import List, Optional
resend.api_key = os.environ["RESEND_API_KEY"]
@dataclass
class ValidationError:
index: int
field: str
message: str
def validate_batch(emails: List[dict]) -> tuple[bool, List[ValidationError]]:
errors = []
email_regex = re.compile(r'^[^\s@]+@[^\s@]+\.[^\s@]+$')
if not emails:
return False, [ValidationError(-1, 'batch', 'Batch cannot be empty')]
if len(emails) > 100:
return False, [ValidationError(-1, 'batch', 'Batch cannot exceed 100 emails')]
for index, email in enumerate(emails):
if not email.get('from'):
errors.append(ValidationError(index, 'from', 'From address is required'))
to_list = email.get('to', [])
if not to_list:
errors.append(ValidationError(index, 'to', 'At least one recipient is required'))
elif len(to_list) > 50:
errors.append(ValidationError(index, 'to', 'Cannot exceed 50 recipients per email'))
else:
for r_index, recipient in enumerate(to_list):
if not email_regex.match(recipient):
errors.append(ValidationError(index, f'to[{r_index}]', f'Invalid email: {recipient}'))
if not email.get('subject'):
errors.append(ValidationError(index, 'subject', 'Subject is required'))
if not email.get('html') and not email.get('text'):
errors.append(ValidationError(index, 'content', 'Either html or text content is required'))
return len(errors) == 0, errors
# Usage
emails = [
{"from": "Acme <[email protected]>", "to": ["[email protected]"], "subject": "Hello", "html": "<p>Hi</p>"},
]
valid, errors = validate_batch(emails)
if not valid:
print(f"Validation failed: {errors}")
else:
result = resend.Batch.send(emails)
Error Handling
Common Error Codes
| Code | Description | Action |
|---|---|---|
| 400 | Bad request (invalid params) | Fix request parameters, don't retry |
| 401 | Invalid API key | Check RESEND_API_KEY, don't retry |
| 403 | Domain not verified | Verify domain at resend.com/domains |
| 409 | Idempotency conflict | Same key with different payload |
| 422 | Unprocessable entity | Check email format/content, don't retry |
| 429 | Rate limited | Retry with exponential backoff |
| 500 | Server error | Retry with exponential backoff |
Node.js
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
const { data, error } = await resend.batch.send(emails);
if (error) {
switch (error.name) {
case 'validation_error':
// Invalid parameters - don't retry, fix the data
console.error('Validation error:', error.message);
throw new Error(`Invalid batch data: ${error.message}`);
case 'rate_limit_exceeded':
// Rate limited - safe to retry with backoff
console.log('Rate limited, should retry with backoff');
break;
case 'api_error':
// Server error - safe to retry
console.log('Server error, should retry');
break;
default:
console.error('Unexpected error:', error);
}
return;
}
console.log('Batch sent successfully:', data);
Python
import resend
import os
resend.api_key = os.environ["RESEND_API_KEY"]
try:
result = resend.Batch.send(emails)
print(f"Batch sent: {result}")
except resend.exceptions.ValidationError as e:
# Invalid parameters - don't retry
print(f"Validation error (don't retry): {e}")
raise
except resend.exceptions.RateLimitError as e:
# Rate limited - retry with backoff
print(f"Rate limited (retry with backoff): {e}")
except resend.exceptions.ResendError as e:
# Other API error - may retry
print(f"API error: {e}")
Retry Logic with Idempotency
Combine retry logic with idempotency keys to safely retry failed batches.
Node.js
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
interface BatchSendOptions {
maxRetries?: number;
idempotencyKey: string;
}
async function sendBatchWithRetry(
emails: Parameters<typeof resend.batch.send>[0],
options: BatchSendOptions
) {
const { maxRetries = 3, idempotencyKey } = options;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const { data, error } = await resend.batch.send(emails, {
idempotencyKey,
});
if (!error) {
return { success: true, data };
}
// Don't retry validation errors
if (error.name === 'validation_error') {
return { success: false, error: error.message, retryable: false };
}
// Don't retry idempotency conflicts
if (error.name === 'idempotency_error') {
return { success: false, error: 'Duplicate request with different payload', retryable: false };
}
// Last attempt failed
if (attempt === maxRetries) {
return { success: false, error: error.message, retryable: true };
}
// Exponential backoff: 1s, 2s, 4s...
const delay = Math.pow(2, attempt) * 1000;
console.log(`Attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
return { success: false, error: 'Max retries exceeded', retryable: true };
}
// Usage
const result = await sendBatchWithRetry(
[
{ from: 'Acme <[email protected]>', to: ['[email protected]'], subject: 'Hello', html: '<p>Hi</p>' },
{ from: 'Acme <[email protected]>', to: ['[email protected]'], subject: 'Hello', html: '<p>Hi</p>' },
],
{ idempotencyKey: `batch-welcome/${batchId}` }
);
if (result.success) {
console.log('Batch sent:', result.data);
} else {
console.error('Batch failed:', result.error);
}
Python
import resend
import os
import time
from dataclasses import dataclass
from typing import Optional, List
resend.api_key = os.environ["RESEND_API_KEY"]
@dataclass
class BatchResult:
success: bool
data: Optional[List[dict]] = None
error: Optional[str] = None
retryable: bool = False
def send_batch_with_retry(
emails: List[dict],
idempotency_key: str,
max_retries: int = 3
) -> BatchResult:
for attempt in range(max_retries + 1):
try:
result = resend.Batch.send(emails, idempotency_key=idempotency_key)
return BatchResult(success=True, data=result)
except resend.exceptions.ValidationError as e:
# Don't retry validation errors
return BatchResult(success=False, error=str(e), retryable=False)
except resend.exceptions.ResendError as e:
if attempt == max_retries:
return BatchResult(success=False, error=str(e), retryable=True)
# Exponential backoff: 1s, 2s, 4s...
delay = 2 ** attempt
print(f"Attempt {attempt + 1} failed, retrying in {delay}s...")
time.sleep(delay)
return BatchResult(success=False, error="Max retries exceeded", retryable=True)
# Usage
result = send_batch_with_retry(
emails=[
{"from": "Acme <[email protected]>", "to": ["[email protected]"], "subject": "Hello", "html": "<p>Hi</p>"},
{"from": "Acme <[email protected]>", "to": ["[email protected]"], "subject": "Hello", "html": "<p>Hi</p>"},
],
idempotency_key=f"batch-welcome/{batch_id}"
)
if result.success:
print(f"Batch sent: {result.data}")
else:
print(f"Batch failed: {result.error}")
Chunking Large Batches
For sends larger than 100 emails, chunk into multiple batch requests.
Node.js
import { Resend } from 'resend';
import { randomUUID } from 'crypto';
const resend = new Resend(process.env.RESEND_API_KEY);
const BATCH_SIZE = 100;
interface Email {
from: string;
to: string[];
subject: string;
html: string;
}
async function sendLargeBatch(emails: Email[], batchPrefix: string) {
const chunks: Email[][] = [];
for (let i = 0; i < emails.length; i += BATCH_SIZE) {
chunks.push(emails.slice(i, i + BATCH_SIZE));
}
const results = await Promise.all(
chunks.map(async (chunk, index) => {
const idempotencyKey = `${batchPrefix}/chunk-${index}`;
const { data, error } = await resend.batch.send(chunk, { idempotencyKey });
return {
chunkIndex: index,
success: !error,
data,
error: error?.message,
};
})
);
const successful = results.filter(r => r.success);
const failed = results.filter(r => !r.success);
return {
totalChunks: chunks.length,
successful: successful.length,
failed: failed.length,
results,
};
}
// Usage: Send 250 emails
const emails = generateEmails(250); // Your email generation logic
const result = await sendLargeBatch(emails, `campaign-${randomUUID()}`);
console.log(`Sent ${result.successful}/${result.totalChunks} chunks successfully`);
Python
import resend
import os
import uuid
from typing import List
from concurrent.futures import ThreadPoolExecutor, as_completed
resend.api_key = os.environ["RESEND_API_KEY"]
BATCH_SIZE = 100
def chunk_list(lst: List, size: int) -> List[List]:
return [lst[i:i + size] for i in range(0, len(lst), size)]
def send_chunk(chunk: List[dict], idempotency_key: str) -> dict:
try:
result = resend.Batch.send(chunk, idempotency_key=idempotency_key)
return {"success": True, "data": result}
except Exception as e:
return {"success": False, "error": str(e)}
def send_large_batch(emails: List[dict], batch_prefix: str) -> dict:
chunks = chunk_list(emails, BATCH_SIZE)
results = []
# Send chunks in parallel (adjust max_workers as needed)
with ThreadPoolExecutor(max_workers=5) as executor:
futures = {
executor.submit(
send_chunk,
chunk,
f"{batch_prefix}/chunk-{index}"
): index
for index, chunk in enumerate(chunks)
}
for future in as_completed(futures):
index = futures[future]
result = future.result()
result["chunk_index"] = index
results.append(result)
successful = [r for r in results if r["success"]]
failed = [r for r in results if not r["success"]]
return {
"total_chunks": len(chunks),
"successful": len(successful),
"failed": len(failed),
"results": results,
}
# Usage: Send 250 emails
emails = generate_emails(250) # Your email generation logic
result = send_large_batch(emails, f"campaign-{uuid.uuid4()}")
print(f"Sent {result['successful']}/{result['total_chunks']} chunks successfully")
Production-Ready Implementations
Node.js - Complete Batch Email Service
import { Resend } from 'resend';
import { randomUUID } from 'crypto';
const resend = new Resend(process.env.RESEND_API_KEY);
interface BatchEmail {
from: string;
to: string[];
subject: string;
html?: string;
text?: string;
replyTo?: string;
cc?: string[];
bcc?: string[];
tags?: { name: string; value: string }[];
}
interface BatchSendOptions {
idempotencyKey?: string;
maxRetries?: number;
validateBeforeSend?: boolean;
}
interface BatchResult {
success: boolean;
data?: { id: string }[];
error?: string;
retryable: boolean;
validationErrors?: { index: number; field: string; message: string }[];
}
class BatchEmailService {
private maxBatchSize = 100;
private maxRecipientsPerEmail = 50;
validate(emails: BatchEmail[]): { valid: boolean; errors: BatchResult['validationErrors'] } {
const errors: NonNullable<BatchResult['validationErrors']> = [];
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (emails.length === 0) {
errors.push({ index: -1, field: 'batch', message: 'Batch cannot be empty' });
return { valid: false, errors };
}
if (emails.length > this.maxBatchSize) {
errors.push({ index: -1, field: 'batch', message: `Batch cannot exceed ${this.maxBatchSize} emails` });
return { valid: false, errors };
}
emails.forEach((email, index) => {
if (!email.from) {
errors.push({ index, field: 'from', message: 'From address is required' });
}
if (!email.to?.length) {
errors.push({ index, field: 'to', message: 'At least one recipient is required' });
} else if (email.to.length > this.maxRecipientsPerEmail) {
errors.push({ index, field: 'to', message: `Cannot exceed ${this.maxRecipientsPerEmail} recipients` });
} else {
email.to.forEach((r, i) => {
if (!emailRegex.test(r)) {
errors.push({ index, field: `to[${i}]`, message: `Invalid email: ${r}` });
}
});
}
if (!email.subject) {
errors.push({ index, field: 'subject', message: 'Subject is required' });
}
if (!email.html && !email.text) {
errors.push({ index, field: 'content', message: 'Either html or text is required' });
}
});
return { valid: errors.length === 0, errors };
}
async send(emails: BatchEmail[], options: BatchSendOptions = {}): Promise<BatchResult> {
const {
idempotencyKey = `batch-${randomUUID()}`,
maxRetries = 3,
validateBeforeSend = true,
} = options;
// Validate first
if (validateBeforeSend) {
const validation = this.validate(emails);
if (!validation.valid) {
return {
success: false,
error: 'Validation failed',
retryable: false,
validationErrors: validation.errors,
};
}
}
// Send with retry
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const { data, error } = await resend.batch.send(emails, { idempotencyKey });
if (!error) {
return { success: true, data, retryable: false };
}
// Non-retryable errors
if (error.name === 'validation_error' || error.name === 'not_found') {
return { success: false, error: error.message, retryable: false };
}
if (attempt < maxRetries) {
const delay = Math.pow(2, attempt) * 1000;
await new Promise(r => setTimeout(r, delay));
}
}
return { success: false, error: 'Max retries exceeded', retryable: true };
}
async sendLarge(emails: BatchEmail[], batchPrefix: string): Promise<{
totalEmails: number;
totalChunks: number;
successfulChunks: number;
failedChunks: number;
sentEmailIds: string[];
errors: { chunkIndex: number; error: string }[];
}> {
const chunks: BatchEmail[][] = [];
for (let i = 0; i < emails.length; i += this.maxBatchSize) {
chunks.push(emails.slice(i, i + this.maxBatchSize));
}
const results = await Promise.all(
chunks.map((chunk, index) =>
this.send(chunk, { idempotencyKey: `${batchPrefix}/chunk-${index}` })
.then(result => ({ chunkIndex: index, ...result }))
)
);
const successful = results.filter(r => r.success);
const failed = results.filter(r => !r.success);
return {
totalEmails: emails.length,
totalChunks: chunks.length,
successfulChunks: successful.length,
failedChunks: failed.length,
sentEmailIds: successful.flatMap(r => r.data?.map(d => d.id) || []),
errors: failed.map(r => ({ chunkIndex: r.chunkIndex, error: r.error || 'Unknown error' })),
};
}
}
// Usage
const batchService = new BatchEmailService();
// Simple batch
const result = await batchService.send([
{ from: 'Acme <[email protected]>', to: ['[email protected]'], subject: 'Hello', html: '<p>Hi</p>' },
{ from: 'Acme <[email protected]>', to: ['[email protected]'], subject: 'Hello', html: '<p>Hi</p>' },
], { idempotencyKey: `welcome-batch/${batchId}` });
// Large batch (auto-chunked)
const largeResult = await batchService.sendLarge(emails, `campaign-${campaignId}`);
Python - Complete Batch Email Service
import resend
import os
import re
import time
import uuid
from dataclasses import dataclass, field
from typing import List, Optional
from concurrent.futures import ThreadPoolExecutor, as_completed
resend.api_key = os.environ["RESEND_API_KEY"]
@dataclass
class ValidationError:
index: int
field: str
message: str
@dataclass
class BatchResult:
success: bool
data: Optional[List[dict]] = None
error: Optional[str] = None
retryable: bool = False
validation_errors: List[ValidationError] = field(default_factory=list)
@dataclass
class LargeBatchResult:
total_emails: int
total_chunks: int
successful_chunks: int
failed_chunks: int
sent_email_ids: List[str]
errors: List[dict]
class BatchEmailService:
MAX_BATCH_SIZE = 100
MAX_RECIPIENTS_PER_EMAIL = 50
def __init__(self):
self.email_regex = re.compile(r'^[^\s@]+@[^\s@]+\.[^\s@]+$')
def validate(self, emails: List[dict]) -> tuple[bool, List[ValidationError]]:
errors = []
if not emails:
errors.append(ValidationError(-1, 'batch', 'Batch cannot be empty'))
return False, errors
if len(emails) > self.MAX_BATCH_SIZE:
errors.append(ValidationError(-1, 'batch', f'Batch cannot exceed {self.MAX_BATCH_SIZE} emails'))
return False, errors
for index, email in enumerate(emails):
if not email.get('from'):
errors.append(ValidationError(index, 'from', 'From address is required'))
to_list = email.get('to', [])
if not to_list:
errors.append(ValidationError(index, 'to', 'At least one recipient is required'))
elif len(to_list) > self.MAX_RECIPIENTS_PER_EMAIL:
errors.append(ValidationError(index, 'to', f'Cannot exceed {self.MAX_RECIPIENTS_PER_EMAIL} recipients'))
else:
for r_idx, recipient in enumerate(to_list):
if not self.email_regex.match(recipient):
errors.append(ValidationError(index, f'to[{r_idx}]', f'Invalid email: {recipient}'))
if not email.get('subject'):
errors.append(ValidationError(index, 'subject', 'Subject is required'))
if not email.get('html') and not email.get('text'):
errors.append(ValidationError(index, 'content', 'Either html or text is required'))
return len(errors) == 0, errors
def send(
self,
emails: List[dict],
idempotency_key: Optional[str] = None,
max_retries: int = 3,
validate_before_send: bool = True
) -> BatchResult:
idempotency_key = idempotency_key or f"batch-{uuid.uuid4()}"
# Validate first
if validate_before_send:
valid, errors = self.validate(emails)
if not valid:
return BatchResult(
success=False,
error='Validation failed',
retryable=False,
validation_errors=errors
)
# Send with retry
for attempt in range(max_retries + 1):
try:
result = resend.Batch.send(emails, idempotency_key=idempotency_key)
return BatchResult(success=True, data=result)
except resend.exceptions.ValidationError as e:
return BatchResult(success=False, error=str(e), retryable=False)
except resend.exceptions.ResendError as e:
if attempt < max_retries:
time.sleep(2 ** attempt)
else:
return BatchResult(success=False, error=str(e), retryable=True)
return BatchResult(success=False, error='Max retries exceeded', retryable=True)
def send_large(self, emails: List[dict], batch_prefix: str, max_workers: int = 5) -> LargeBatchResult:
chunks = [emails[i:i + self.MAX_BATCH_SIZE] for i in range(0, len(emails), self.MAX_BATCH_SIZE)]
results = []
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(
self.send,
chunk,
f"{batch_prefix}/chunk-{idx}"
): idx
for idx, chunk in enumerate(chunks)
}
for future in as_completed(futures):
idx = futures[future]
result = future.result()
results.append({"chunk_index": idx, "result": result})
successful = [r for r in results if r["result"].success]
failed = [r for r in results if not r["result"].success]
return LargeBatchResult(
total_emails=len(emails),
total_chunks=len(chunks),
successful_chunks=len(successful),
failed_chunks=len(failed),
sent_email_ids=[
item["id"]
for r in successful
if r["result"].data
for item in r["result"].data
],
errors=[
{"chunk_index": r["chunk_index"], "error": r["result"].error}
for r in failed
]
)
# Usage
service = BatchEmailService()
# Simple batch
result = service.send([
{"from": "Acme <[email protected]>", "to": ["[email protected]"], "subject": "Hello", "html": "<p>Hi</p>"},
{"from": "Acme <[email protected]>", "to": ["[email protected]"], "subject": "Hello", "html": "<p>Hi</p>"},
], idempotency_key=f"welcome-batch/{batch_id}")
# Large batch (auto-chunked)
large_result = service.send_large(emails, f"campaign-{campaign_id}")