Receiving emails from external sources introduces security considerations. This guide covers best practices for securing your inbound email processing pipeline.
Webhook Security
Verify Webhook Credentials
Always verify that webhooks come from Lettr, not malicious actors. Lettr supports Basic Auth and OAuth 2.0 for webhook authentication — configure your preferred method when creating the webhook in the dashboard.
app.post('/webhooks/inbound', (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Basic ')) {
return res.sendStatus(401);
}
const credentials = Buffer.from(authHeader.slice(6), 'base64').toString();
const [username, password] = credentials.split(':');
if (username !== process.env.WEBHOOK_USERNAME || password !== process.env.WEBHOOK_PASSWORD) {
return res.sendStatus(401);
}
next();
}, express.json(), async (req, res) => {
for (const event of req.body) {
const relay = event.msys?.relay_message;
if (!relay) continue;
await processEmail(relay);
}
res.sendStatus(200);
});
See Webhook Authorization for details on configuring Basic Auth and OAuth 2.0.
IP Allowlisting
You can optionally restrict webhook access to known IP addresses as an additional layer of defense:
function ipAllowlist(allowedIPs) {
return (req, res, next) => {
const clientIP = req.ip || req.connection.remoteAddress;
if (!allowedIPs.includes(clientIP)) {
console.warn(`Rejected webhook from unauthorized IP: ${clientIP}`);
return res.sendStatus(403);
}
next();
};
}
// Configure with your known webhook source IPs
const allowedIPs = process.env.WEBHOOK_ALLOWED_IPS?.split(',') || [];
app.post('/webhooks/inbound', ipAllowlist(allowedIPs), webhookHandler);
IP allowlisting should be used as a supplementary security measure alongside credential verification, not as a replacement.
Validate Email Addresses
Never trust email addresses from inbound emails:
function validateEmailAddress(email) {
if (!email || typeof email !== 'string') {
return false;
}
// Basic format check
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return false;
}
// Length limits
if (email.length > 254) {
return false;
}
const [localPart, domain] = email.split('@');
if (localPart.length > 64 || domain.length > 255) {
return false;
}
return true;
}
Sanitize Email Content
Sanitize HTML content before displaying or storing:
import DOMPurify from 'isomorphic-dompurify';
function sanitizeHtml(html) {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: [
'p', 'br', 'b', 'i', 'u', 'strong', 'em',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li', 'a', 'img', 'blockquote',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
'span', 'div'
],
ALLOWED_ATTR: ['href', 'src', 'alt', 'class', 'style'],
ALLOW_DATA_ATTR: false,
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form'],
FORBID_ATTR: ['onerror', 'onload', 'onclick']
});
}
app.post('/webhooks/inbound', express.json(), async (req, res) => {
for (const event of req.body) {
const relay = event.msys?.relay_message;
if (!relay) continue;
const { html, text } = relay.content;
// Sanitize before storing
const safeHtml = html ? sanitizeHtml(html) : null;
const safeText = text ? escapeHtml(text) : null;
await storeEmail({ html: safeHtml, text: safeText });
}
res.sendStatus(200);
});
Validate and Sanitize Filenames
Never use attachment filenames directly:
import path from 'path';
function sanitizeFilename(filename) {
if (!filename || typeof filename !== 'string') {
return 'unnamed_file';
}
// Remove directory traversal attempts
let safe = path.basename(filename);
// Remove dangerous characters
safe = safe.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
// Limit length
safe = safe.substring(0, 200);
// Ensure not empty
if (!safe || safe === '.' || safe === '..') {
safe = 'unnamed_file';
}
return safe;
}
// Generate unique filenames to prevent overwrites
function generateSafeFilename(originalFilename) {
const safe = sanitizeFilename(originalFilename);
const ext = path.extname(safe);
const base = path.basename(safe, ext);
const timestamp = Date.now();
const random = crypto.randomBytes(4).toString('hex');
return `${base}_${timestamp}_${random}${ext}`;
}
Attachment Security
Validate File Types
Never trust the declared content type:
import { fileTypeFromBuffer } from 'file-type';
const ALLOWED_TYPES = new Map([
['application/pdf', ['.pdf']],
['image/jpeg', ['.jpg', '.jpeg']],
['image/png', ['.png']],
['image/gif', ['.gif']],
['text/plain', ['.txt']],
['text/csv', ['.csv']]
]);
async function validateAttachment(attachment) {
const response = await fetch(attachment.url);
const buffer = Buffer.from(await response.arrayBuffer());
// Detect actual file type from content
const detected = await fileTypeFromBuffer(buffer);
if (!detected) {
// Could be text file
if (attachment.contentType.startsWith('text/')) {
return { valid: true, type: attachment.contentType };
}
return { valid: false, reason: 'Unknown file type' };
}
// Check if type is allowed
if (!ALLOWED_TYPES.has(detected.mime)) {
return { valid: false, reason: `File type not allowed: ${detected.mime}` };
}
// Verify declared type matches detected type
if (detected.mime !== attachment.contentType) {
console.warn(
`Content-Type mismatch: declared ${attachment.contentType}, detected ${detected.mime}`
);
// Use detected type, not declared type
}
return { valid: true, type: detected.mime, buffer };
}
Scan for Malware
Integrate with a virus scanner:
import ClamScan from 'clamscan';
const clamscan = await new ClamScan().init({
clamdscan: {
host: process.env.CLAMAV_HOST || 'localhost',
port: 3310
}
});
async function scanForMalware(buffer, filename) {
try {
const { isInfected, viruses } = await clamscan.scanBuffer(buffer);
if (isInfected) {
console.error(`Malware detected in ${filename}: ${viruses.join(', ')}`);
return {
safe: false,
threats: viruses
};
}
return { safe: true };
} catch (error) {
console.error('Virus scan failed:', error);
// Fail closed - treat as unsafe if scanning fails
return { safe: false, error: 'Scan failed' };
}
}
Limit File Sizes
Enforce size limits to prevent resource exhaustion:
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10 MB
const MAX_TOTAL_SIZE = 25 * 1024 * 1024; // 25 MB
function validateAttachmentSizes(attachments) {
let totalSize = 0;
for (const attachment of attachments) {
if (attachment.size > MAX_ATTACHMENT_SIZE) {
return {
valid: false,
reason: `Attachment ${attachment.filename} exceeds size limit`
};
}
totalSize += attachment.size;
}
if (totalSize > MAX_TOTAL_SIZE) {
return {
valid: false,
reason: `Total attachment size exceeds limit`
};
}
return { valid: true };
}
Content Security
Prevent XSS in Displayed Emails
When displaying email content in a web interface:
// Use CSP headers
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; img-src 'self' https:; style-src 'self' 'unsafe-inline'"
);
next();
});
// Render emails in sandboxed iframe
function renderEmailHtml(sanitizedHtml) {
return `
<iframe
sandbox="allow-same-origin"
srcdoc="${escapeAttribute(sanitizedHtml)}"
style="width: 100%; height: 500px; border: none;"
></iframe>
`;
}
Block Tracking Pixels
Strip tracking pixels from received emails:
function removeTrackingPixels(html) {
// Remove 1x1 images (common tracking pixels)
html = html.replace(
/<img[^>]*(?:width|height)\s*=\s*["']?1["']?[^>]*>/gi,
''
);
// Remove images from known tracking domains
const trackingDomains = [
'mailtrack.io',
'pixel.watch',
'track.customer.io',
'open.convertkit.com'
];
for (const domain of trackingDomains) {
const regex = new RegExp(`<img[^>]*${domain}[^>]*>`, 'gi');
html = html.replace(regex, '');
}
return html;
}
Rate Limiting
Protect against email flooding:
import { RateLimiterRedis } from 'rate-limiter-flexible';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
const senderLimiter = new RateLimiterRedis({
storeClient: redis,
keyPrefix: 'inbound_sender',
points: 100, // 100 emails
duration: 3600 // per hour
});
const domainLimiter = new RateLimiterRedis({
storeClient: redis,
keyPrefix: 'inbound_domain',
points: 500,
duration: 3600
});
const globalLimiter = new RateLimiterRedis({
storeClient: redis,
keyPrefix: 'inbound_global',
points: 10000,
duration: 3600
});
async function checkRateLimits(email) {
const sender = email.from.toLowerCase();
const domain = sender.split('@')[1];
try {
await Promise.all([
senderLimiter.consume(sender),
domainLimiter.consume(domain),
globalLimiter.consume('global')
]);
return { allowed: true };
} catch (error) {
return {
allowed: false,
reason: 'Rate limit exceeded',
retryAfter: Math.ceil(error.msBeforeNext / 1000)
};
}
}
Logging and Monitoring
Audit Logging
Log all inbound email processing:
async function logInboundEmail(email, result) {
await auditLog.create({
type: 'inbound_email',
emailId: email.id,
from: email.from,
to: email.to,
subject: email.subject?.substring(0, 100),
attachmentCount: email.attachments?.length || 0,
result: result.status,
processingTime: result.duration,
timestamp: new Date()
});
}
Anomaly Detection
Monitor for suspicious patterns:
class AnomalyDetector {
constructor() {
this.senderHistory = new Map();
}
async analyze(email) {
const sender = email.from.toLowerCase();
const alerts = [];
// Track sender frequency
const history = this.senderHistory.get(sender) || { count: 0, firstSeen: Date.now() };
history.count++;
this.senderHistory.set(sender, history);
// New sender sending many emails quickly
if (history.count > 10 && Date.now() - history.firstSeen < 60000) {
alerts.push({
type: 'rapid_sender',
sender,
count: history.count,
duration: Date.now() - history.firstSeen
});
}
// Unusual attachment patterns
if (email.attachments?.length > 5) {
alerts.push({
type: 'many_attachments',
sender,
count: email.attachments.length
});
}
return alerts;
}
}
Security Checklist
Never trust any data from inbound emails. Senders can forge headers, spoof addresses, and include malicious content. Always validate and sanitize everything.