Email Security: Preventing Abuse and Protecting Recipients
Email security isn't optional: leaked API keys and spoofed domains turn your infrastructure into an attack vector. Here's how to lock it down.
One leaked API key is all it takes: a single tratto_live_ string in a public repo, and someone else is sending phishing emails from your domain, to your users, with your reputation attached.
Email is critical infrastructure, and it gets attacked like one: credential theft, spoofing, injection, list abuse. None of it requires a sophisticated attacker, just a gap you didn't close.
Here's every layer worth closing.
API Key Management
Never commit API keys to git. Ever.
// ❌ Bad
export const tratto = new Tratto('tratto_live_abc123xyz')
// ✅ Good
export const tratto = new Tratto(process.env.TRATTO_API_KEY!)Use environment variables. Rotate keys on a schedule you actually keep.
If a key leaks, rotate it immediately:
# In Tratto dashboard: generate new key
# Update environment variable
# Deploy
# Revoke old keyRate Limiting
One unthrottled endpoint is all a bot needs to burn through your sending reputation in an hour. Rate limit before it happens:
import { createClient } from 'redis'
import { Tratto, type SendEmailParams } from '@tratto/email'
const redis = createClient({ url: process.env.REDIS_URL })
await redis.connect()
const tratto = new Tratto(process.env.TRATTO_API_KEY!)
export async function sendEmail(params: SendEmailParams) {
const key = `ratelimit:${params.to}`
const count = await redis.incr(key)
if (count === 1) {
await redis.expire(key, 60 * 60) // 1 hour window
}
if (count > 10) {
throw new Error('Rate limit exceeded')
}
return await tratto.emails.send(params)
}Don't want to run Redis yourself? Upstash offers a serverless Redis built for exactly this: its free tier gives you 256 MB data, 10 GB monthly bandwidth, and 500K monthly commands at $0/month → plenty for a prototype or early-stage product.
Email Verification
Verify email ownership before trusting it. Use a confirmation token:
Requesting Verification
import crypto from 'crypto'
export async function initiateEmailVerification(email: string) {
const token = crypto.randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours
await db.emailVerifications.create({
token: hashToken(token),
email,
expiresAt,
})
const verificationUrl = `https://yourapp.com/verify?token=${token}`
await tratto.emails.send({
from: '[email protected]',
to: email,
subject: 'Verify your email',
html: `<a href="${verificationUrl}">Verify email</a>`,
})
}from is required on every send, and the domain it uses has to be one you've verified.
Confirming the Token
export async function verifyEmail(token: string) {
const verification = await db.emailVerifications.findOne({
token: hashToken(token),
expiresAt: { $gt: new Date() },
})
if (!verification) {
throw new Error('Invalid or expired token')
}
// Mark email as verified
await db.users.update(
{ email: verification.email },
{ emailVerified: true }
)
// Invalidate token
await db.emailVerifications.delete({ id: verification.id })
}Protect Against Injection
Any field a user controls, a subject line, a custom field, a template variable, is a potential injection point. Sanitize before it reaches the send call:
import DOMPurify from 'isomorphic-dompurify'
export async function sendCustomEmail(to: string, userContent: string) {
// Sanitize user input to prevent HTML injection
const sanitized = DOMPurify.sanitize(userContent, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href'],
})
await tratto.emails.send({
from: '[email protected]',
to,
subject: 'A message for you',
html: sanitized,
})
}Skipping this step turns your transactional email into someone else's phishing kit.
DMARC Reporting
Someone spoofing your domain doesn't just hurt them, it burns your sender reputation for every legitimate email you send after. Monitor DMARC reports to catch it early:
- Configure DMARC reporting:
rua=mailto:[email protected] - Review the aggregate reports for unauthorized senders
- Alert if the failure rate is high
Catch it in reports before it shows up as a spike in bounces.
Audit Logs
When something goes wrong, "who sent what, when" is the first question you'll need answered, and the only way to answer it fast is to already have it logged:
import type { SendEmailParams } from '@tratto/email'
export async function sendEmailWithAudit(params: SendEmailParams, userId: string) {
// send() resolves to { id }, not a bare string
const { id } = await tratto.emails.send(params)
// Log the send
await db.auditLogs.create({
event: 'email.sent',
userId,
emailId: id,
to: params.to,
subject: params.subject,
timestamp: new Date(),
})
return id
}Later, query the audit log:
// Did this user send suspicious emails?
const recentEmails = await db.auditLogs.find({
userId,
event: 'email.sent',
timestamp: { $gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }, // Last 7 days
})List Hygiene
A list full of dead addresses tanks your sender reputation and drags every future send down with it.
Tratto already does the load-bearing half for you: a hard bounce or a spam complaint suppresses that contact automatically, and three soft bounces do the same. You don't have to build the suppression, and you can't accidentally keep mailing an address that hard-bounced.
What's still worth doing is mirroring it into your own database, so your app knows why it stopped emailing someone:
export async function cleanupBounceList() {
// Get emails bounced in last 30 days
const bouncedEmails = await db.emailBounces.find({
timestamp: { $gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) },
})
// Reflect it in your own user records
for (const bounce of bouncedEmails) {
await db.contacts.update(
{ email: bounce.email },
{ suppressSend: true, bounceReason: bounce.reason }
)
}
}Verify Webhook Signatures
Covered in more depth in Real-Time Email Events via Webhooks: the short version is, always verify the signature before trusting a payload. It's the only proof the webhook actually came from Tratto.
The header is X-Tratto-Signature, in the form t=<timestamp>,v1=<hmac>, and the HMAC covers ${timestamp}.${body}—not the body on its own. Hashing the bare body and comparing it to the whole header rejects every legitimate webhook you will ever receive:
import crypto from 'crypto'
const TOLERANCE_MS = 5 * 60 * 1000
export async function verifyTrattoWebhook(req: Request) {
const header = req.headers.get('x-tratto-signature')
const body = await req.text()
if (!header) throw new Error('Missing webhook signature')
const parts = new Map(
header.split(',').map((part) => {
const [key, value] = part.split('=')
return [key, value] as const
}),
)
const timestamp = parts.get('t')
const received = parts.get('v1')
if (!timestamp || !received) throw new Error('Malformed webhook signature')
// Stale signatures stay cryptographically valid forever: bound them.
if (Math.abs(Date.now() - Number(timestamp)) > TOLERANCE_MS) {
throw new Error('Webhook timestamp outside tolerance')
}
const expected = crypto
.createHmac('sha256', process.env.TRATTO_WEBHOOK_SECRET!)
.update(`${timestamp}.${body}`)
.digest('hex')
// timingSafeEqual throws RangeError when the buffers differ in length.
const a = Buffer.from(expected, 'utf8')
const b = Buffer.from(received, 'utf8')
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error('Invalid webhook signature')
}
return JSON.parse(body)
}In Summary
- 🔐 Rotate API keys on a schedule
- ⏱️ Rate limit per user/IP
- ✅ Verify emails before trusting them
- 🧹 Sanitize user-provided content
- 📊 Monitor DMARC reports
- 📝 Maintain audit logs
- 🗑️ Mirror Tratto's automatic suppression into your own records
- ✍️ Verify webhook signatures, timestamp included
Email security isn't optional. It's foundation work.
Want the Tratto-side reference? Read the docs on API keys and webhook signature verification.