Email API Design Patterns for Developers
Best practices for integrating email APIs into your application, including error handling and retry strategies.
Alex Kim
Developer Advocate
Integrating an email API seems straightforward - until you hit production. Here are proven patterns for building robust email functionality into your applications.
The Basics Done Right
Always Send Asynchronously
Never block your user's request waiting for an email to send:
// ❌ Bad: Blocking the request
app.post('/register', async (req, res) => {
const user = await createUser(req.body);
await sendWelcomeEmail(user); // User waits for this
res.json({ success: true });// ✅ Good: Queue the email app.post('/register', async (req, res) => { const user = await createUser(req.body); await emailQueue.add('welcome', { userId: user.id }); res.json({ success: true }); // Respond immediately }); ```
Idempotency Keys
Prevent duplicate emails when retries happen:
const response = await postalynk.send({
idempotencyKey: `order-confirmation-${orderId}`,
to: customer.email,
subject: 'Order Confirmed',
// ...
});If you retry with the same key, Postalynk returns the original response instead of sending a duplicate.
Validate Before Sending
Catch problems early:
function validateEmailRequest(params) {
if (!params.to || !isValidEmail(params.to)) {
throw new Error('Invalid recipient');
}
if (!params.subject || params.subject.length > 998) {
throw new Error('Invalid subject');
}
if (!params.html && !params.text && !params.templateId) {
throw new Error('No content provided');
}
}Error Handling
Categorize Errors
Not all errors are equal:
try {
await postalynk.send(emailParams);
} catch (error) {
if (error.type === 'validation_error') {
// Bad request - don't retry
logger.error('Invalid email params', { error, params: emailParams });
return { success: false, reason: 'invalid_params' };if (error.type === 'rate_limit') { // Retry after delay await emailQueue.add('send', emailParams, { delay: error.retryAfter * 1000 }); return { success: true, queued: true }; }
if (error.type === 'server_error') { // Transient error - retry with backoff throw error; // Let queue handle retry } } ```
Exponential Backoff
For transient failures, increase delay between retries:
async function sendWithRetry(params, attempt = 0) { try { return await postalynk.send(params); } catch (error) { if (attempt >= retryDelays.length || !isRetryable(error)) { throw error; } await sleep(retryDelays[attempt]); return sendWithRetry(params, attempt + 1); } } ```
Circuit Breaker Pattern
Stop hammering a failing service:
class EmailCircuitBreaker {
constructor() {
this.failures = 0;
this.lastFailure = null;
this.state = 'closed'; // closed, open, half-openasync send(params) { if (this.state === 'open') { if (Date.now() - this.lastFailure > 30000) { this.state = 'half-open'; } else { throw new Error('Circuit breaker open'); } }
try { const result = await postalynk.send(params); this.onSuccess(); return result; } catch (error) { this.onFailure(); throw error; } }
onSuccess() { this.failures = 0; this.state = 'closed'; }
onFailure() { this.failures++; this.lastFailure = Date.now(); if (this.failures >= 5) { this.state = 'open'; } } } ```
Template Patterns
Centralize Template Logic
Don't scatter template IDs across your codebase:
// email-templates.js
export const templates = {
WELCOME: 'welcome-v2',
PASSWORD_RESET: 'password-reset-v1',
ORDER_CONFIRMATION: 'order-confirmation-v3',
// ...export function sendWelcomeEmail(user) {
return postalynk.send({
to: user.email,
templateId: templates.WELCOME,
variables: {
name: user.firstName,
loginUrl: ${config.appUrl}/login,
},
});
}
```
Version Your Templates
When updating templates, keep the old version:
welcome-v1 (deprecated, still used by some flows)
welcome-v2 (current production)
welcome-v3 (testing/staging)Provide Fallbacks
Always include plain text:
await postalynk.send({
to: recipient,
subject: 'Your Order',
html: renderHtmlTemplate(data),
text: renderPlainTextTemplate(data), // Fallback for text-only clients
});Webhook Handling
Verify Signatures
Never trust webhook payloads without verification:
app.post('/webhooks/email', (req, res) => {
const signature = req.headers['x-postalynk-signature'];
const isValid = verifySignature(
req.rawBody,
signature,
process.env.WEBHOOK_SECRETif (!isValid) { return res.status(401).json({ error: 'Invalid signature' }); }
// Process webhook... res.json({ received: true }); }); ```
Respond Quickly
Process webhooks asynchronously to avoid timeouts:
app.post('/webhooks/email', async (req, res) => {// Queue for processing await webhookQueue.add('process', req.body);
// Respond immediately res.json({ received: true }); }); ```
Handle Duplicates
Webhooks can be delivered multiple times. Use idempotent processing:
async function processDeliveryWebhook(event) {// Check if already processed if (await redis.get(lockKey)) { return; // Already handled }
// Process the event await updateMessageStatus(event.messageId, event.status);
// Mark as processed (expire after 24h) await redis.setex(lockKey, 86400, '1'); } ```
Testing Strategies
Mock in Unit Tests
Don't hit real APIs in unit tests:
jest.mock('./email-client', () => ({
send: jest.fn().mockResolvedValue({ messageId: 'test-123' }),test('sends welcome email on registration', async () => { await registerUser({ email: 'test@example.com' });
expect(emailClient.send).toHaveBeenCalledWith( expect.objectContaining({ to: 'test@example.com', templateId: 'welcome-v2', }) ); }); ```
Use Test Mode Keys
Postalynk test keys (mf_test_) log emails without sending:
const postalynk = new Postalynk({
apiKey: process.env.NODE_ENV === 'production'
? process.env.POSTALYNK_LIVE_KEY
: process.env.POSTALYNK_TEST_KEY,
});Integration Test with Webhooks
Use webhook.site or similar for integration tests:
test('delivery webhook fires on send', async () => {await postalynk.send({ to: 'test@example.com', subject: 'Test', text: 'Test message', webhookUrl, // Per-message webhook override });
// Poll for webhook delivery const event = await waitForWebhook(webhookUrl); expect(event.type).toBe('delivered'); }); ```
Monitoring and Observability
Track Key Metrics
const metrics = {
emailsSent: new Counter('emails_sent_total'),
emailsFailed: new Counter('emails_failed_total'),
sendLatency: new Histogram('email_send_duration_seconds'),async function sendEmail(params) { const timer = metrics.sendLatency.startTimer();
try { const result = await postalynk.send(params); metrics.emailsSent.inc({ type: params.type }); return result; } catch (error) { metrics.emailsFailed.inc({ type: params.type, error: error.code }); throw error; } finally { timer(); } } ```
Log Structured Data
logger.info('Email sent', {
messageId: result.messageId,
recipient: params.to,
template: params.templateId,
duration: endTime - startTime,
});Conclusion
Robust email integration requires attention to error handling, retries, and testing. These patterns will help you build email functionality that works reliably in production.
Postalynk provides the tools to implement these patterns easily - from idempotency keys to webhook signatures to test mode. Start with the basics and add complexity as your needs grow.
Related Articles
Transactional vs Marketing Emails: What's the Difference?
Understanding the key differences between transactional and marketing emails, and when to use each type.
EngineeringScaling Your Email Infrastructure: Lessons from Sending 1B Emails
Insights and lessons learned from scaling our email infrastructure to handle billions of emails per month.