Skip to main content
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.

Input Validation

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

  • Verify webhook credentials (Basic Auth or OAuth 2.0) on every request
  • Store credentials securely in environment variables
  • Use HTTPS for your webhook endpoint
  • Consider IP allowlisting as additional protection
  • Validate all email addresses
  • Sanitize HTML content before storage/display
  • Sanitize filenames before use
  • Validate attachment content types
  • Verify file types from content, not headers
  • Scan attachments for malware
  • Enforce size limits
  • Store attachments outside web root
  • Log all inbound email processing
  • Monitor for rate limit violations
  • Alert on anomalous patterns
  • Track spam score distributions
Never trust any data from inbound emails. Senders can forge headers, spoof addresses, and include malicious content. Always validate and sanitize everything.