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 sk_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('sk_live_abc123xyz')
# ✅ Good
export const tratto = new Tratto(process.env.TRATTO_API_KEY)Use environment variables. Rotate keys monthly.
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'
const redis = createClient({ url: process.env.REDIS_URL })
await redis.connect()
export async function sendEmail(to: string, ...args: Args) {
const key = `ratelimit:${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({ to, ...args })
}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({
to: email,
subject: 'Verify your email',
html: `<a href="${verificationUrl}">Verify email</a>`,
})
}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({
to,
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] - Tratto can send reports to you automatically
- Review for unauthorized senders
- Alert if failure rate is high
// Example: Check DMARC failures from webhook
if (event.type === 'dmarc.failure') {
// Someone is spoofing your domain
await alertSecurityTeam({
severity: 'high',
message: `DMARC failure: ${event.data.reason}`,
})
}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:
export async function sendEmailWithAudit(
to: string,
subject: string,
userId: string,
...rest: Args
) {
const emailId = await tratto.emails.send({ to, subject, ...rest })
// Log the send
await db.auditLogs.create({
event: 'email.sent',
userId,
emailId,
to,
subject,
timestamp: new Date(),
})
return emailId
}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. Remove inactive and bounced emails regularly:
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) },
})
// Remove from send lists
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:
import crypto from 'crypto'
export async function verifyTrattoWebhook(req: Request) {
const signature = req.headers.get('x-tratto-signature')
const body = await req.text()
const hash = crypto
.createHmac('sha256', process.env.TRATTO_WEBHOOK_SECRET!)
.update(body)
.digest('hex')
if (!crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(signature))) {
throw new Error('Invalid webhook signature')
}
return JSON.parse(body)
}In Summary
- 🔐 Rotate API keys monthly
- ⏱️ Rate limit per user/IP
- ✅ Verify emails before trusting them
- 🧹 Sanitize user-provided content
- 📊 Monitor DMARC reports
- 📝 Maintain audit logs
- 🗑️ Clean bounce lists regularly
- ✍️ Verify webhook signatures
Email security isn't optional. It's foundation work.
Want the Tratto-side reference? Read the docs on API keys and webhook signature verification.
Or skip straight to shipping: join Tratto and let us handle the infrastructure layer, so your team's security work stays focused on the application, not on SPF records.