Email Security

Content scanning, attachment validation, URL reputation checking, and malware detection for outbound emails.

All outbound email passes through multi-layered security scanning before delivery. The @owlat/email-scanner package provides all scanning logic as a shared library, consumed by both apps/api (Convex) and apps/mta.

Content Scanning

Analyzes email subject and HTML body for malicious or unwanted content. All scanners are pure TypeScript with zero dependencies, safe for the Convex serverless runtime.

Spam Keywords

~40 weighted patterns (packages/email-scanner/src/content/spamKeywords.ts) detect common spam phrases. Each keyword carries an internal weight, but a keyword only produces its own flag when that weight is 3 or more — high severity when the weight is 5+, otherwise medium. Lighter keywords (weight 1–2) never flag individually; they just accumulate toward a combined total.

Keyword weightIndividual flag
5+ (e.g. "free money", "congratulations you won", "viagra")High
3–4 (e.g. "large money claim", "verify your account", "online gambling")Medium
1–2 (e.g. "act now", "limited time", "click here", "buy now")Not flagged individually

If no individual keyword flagged but the combined weight of all matched keywords reaches 8 or more, a single medium-severity flag is emitted noting that multiple low-severity spam patterns were detected.

Phishing URL Detection

  • URL shortener detection (bit.ly, t.co, goo.gl, etc.) — medium severity
  • Anchor/href domain mismatch (link text says paypal.com but links to evil.com) — high severity
  • Typosquatting of major brands (paypa1.net, g00gle.io) and suspicious TLDs (.xyz, .top, .club, etc.) — high severity
  • Dangerous URI schemes (data:, javascript:) — high severity

Homoglyph / Unicode Spoofing

Detects mixed-script characters used to impersonate legitimate domains:

  • ~50 confusable character mappings (Cyrillic а U+0430 vs Latin a U+0061, Greek ο U+03BF vs Latin o, etc.)
  • Mixed-script detection in link text and URL hostnames
  • Severity: high (20 pts) — homoglyph spoofing is a strong phishing indicator

Prohibited Content

Pattern matching for high-severity scam content (every match is high severity):

  • Advance fee fraud patterns (nigerian prince, advance fee, 419 scam)
  • Urgent money-movement requests (wire transfer/western union/moneygram + immediately/urgently/today)
  • Requests for sensitive personal information (social security/ssn/credit card numbers)
  • Credential phishing (confirm/verify/update/reset paired with password/login/credential)

Subject Line Analysis

  • ALL CAPS abuse (>50% uppercase characters)
  • Excessive punctuation (3 or more ! characters, or 3 or more ? characters, in the subject)

Scoring

All flags contribute to a composite score:

SeverityPointsExample
High20Homoglyph spoofing, credential phishing, high-weight spam keywords
Medium10URL shorteners, medium-weight spam keywords, combined low-pattern aggregate
Low3Excessive punctuation, ALL CAPS subject

Thresholds:

ScoreLevelAction
0–14CleanAllowed
15–39SuspiciousAllowed with warning stored
40+BlockedSend rejected

File Validation

Validates attachments and media uploads before storage or sending. Pure TypeScript, no external dependencies.

Magic Bytes Detection

Identifies real file type from binary headers (first 16 bytes), regardless of file extension:

File TypeMagic Bytes
PE executable (.exe, .dll)4D 5A (MZ) — dangerous, blocked
ELF binary7F 45 4C 46dangerous, blocked
Mach-O executable (macOS)FE ED FA CE / FE ED FA CF / CF FA ED FE / CA FE BA BE (32-bit, 64-bit, 64-bit LE, universal) — dangerous, blocked
MSI installerD0 CF 11 E0 (OLE compound) — dangerous, blocked
ISO 9660 disk image43 44 30 30 31 (CD001) at offset 0x8001dangerous, blocked
PDF25 50 44 46 (%PDF)
PNG89 50 4E 47
JPEGFF D8 FF
ZIP/DOCX/XLSX50 4B 03 04 (PK)

The ISO 9660 volume descriptor sits past the first-bytes window, so it is caught by an optional deep probe: callers pass the five bytes at offset 0x8001 as the isoProbe argument to detectFileType / validateFile to flag renamed disk images.

Double Extension Detection

Catches attacks that hide executable extensions after document extensions:

  • invoice.pdf.exe — detected and blocked
  • report.docx.js — detected and blocked
  • photo.jpg.scr — detected and blocked

Extension Allowlist

Permitted file types (everything else is blocked):

CategoryExtensions
Images.jpg, .jpeg, .png, .gif, .webp, .ico, .bmp, .tiff, .tif
Documents.pdf, .doc, .docx, .odt, .rtf, .txt, .html, .htm
Spreadsheets.xls, .xlsx, .csv, .ods
Presentations.ppt, .pptx, .odp
Archives.zip, .gz, .tar, .tgz
Audio/Video.mp3, .wav, .ogg, .mp4, .webm
Data.json, .xml, .yaml, .yml
Fonts.woff, .woff2, .ttf, .otf
Calendar.ics, .ical
vCard.vcf

SVG is intentionally excluded by default because it is script-capable and can execute when a recipient opens the attachment directly; opt back in per call site via mergePolicy.

MIME Type Allowlist

Validates declared content types against an explicitly enumerated allowlist of safe MIME types (e.g., image/jpeg, image/png, application/pdf, text/plain). The list is not a wildcard — image/svg+xml, for example, is excluded.

Integration Points

  • delivery/worker.ts — validates each attachment buffer before sending
  • mediaAssets.ts — validates uploads before storing to Convex file storage

URL Reputation

Checks URLs in email content against the Google Safe Browsing API v4.

How It Works

  1. Extract all URLs from the email HTML content
  2. Normalize and hash URLs (SHA-256)
  3. Check cache (urlReputationCache table) for known verdicts
  4. Batch-check uncached URLs against Safe Browsing API (up to 500 per request)
  5. Cache results (24h for clean, 1h for flagged)
  6. Convert flagged URLs to ContentFlag entries — malicious verdicts map to high severity, suspicious verdicts to medium (urlReputationToFlags)

Threat Types

ThreatDescription
MALWARESites hosting malicious software
SOCIAL_ENGINEERINGPhishing and deceptive sites
UNWANTED_SOFTWARESites distributing unwanted software
POTENTIALLY_HARMFUL_APPLICATIONMobile app threats

Campaign vs Transactional

Email TypeBehavior
Campaign sendsBlocking gatecheckUrlReputation runs in campaigns/send.ts and flagged URLs prevent the campaign from sending
Transactional sendsNot run — the transactional reducer in transactional/lifecycle.ts runs scanContent only and never calls checkUrlReputation

Configuration

Requires the GOOGLE_SAFE_BROWSING_API_KEY environment variable set in the Convex dashboard. The free tier allows 10,000 requests per day. When the API key is not configured, URL reputation checking is silently skipped.

ClamAV Malware Scanning

Scans attachment binary data for known malware signatures using ClamAV running as a Docker sidecar alongside the MTA.

Architecture

Convex delivery/worker.ts
    │
    │  POST /scan/attachment
    │  (binary data + X-Filename header)
    ▼
MTA scan endpoint (src/routes/scan.ts)
    │
    ├─ File type validation (magic bytes, extensions)
    │
    └─ ClamAV scan (TCP INSTREAM protocol)
        │
        ▼
    clamd (port 3310)

INSTREAM Protocol

The @owlat/email-scanner ClamAV client communicates with clamd over TCP using the INSTREAM protocol:

  1. Send zINSTREAM\0
  2. Send chunked binary data (4-byte big-endian length prefix + data)
  3. Send zero-length terminator
  4. Read verdict: stream: OK\0 or stream: <virus_name> FOUND\0

Fail-Open Design

If ClamAV is unavailable (container not running, network error, timeout), the scan returns { clean: true, skipped: true } and logs a warning. This prevents ClamAV outages from blocking all email delivery.

Health Check

GET /scan/health (apps/mta/src/routes/scan.ts) returns the ClamAV client status and queue depth:

{
  "clamav": {
    "healthy": true,
    "pingOk": true,
    "activeScanCount": 0,
    "pendingCount": 0
  }
}

healthy reflects the pooled client's last-known state, pingOk is a live PING round-trip, and the two counts expose the connection pool's in-flight and queued work.

Inbound Attachment Scanning

ClamAV is not outbound-only. Every received Postbox message is also scanned as defense-in-depth: scanInboundAttachments (apps/api/convex/mail/delivery.ts) extracts each non-inline attachment leaf from the raw MIME and POSTs it to the same MTA /scan/attachment endpoint. A confirmed-infected verdict sets virusVerdict: 'infected' and routes the message to Spam/quarantine; a scanner outage fails open with virusVerdict: 'skipped' (the message still delivers, surfaced via scannerHealth.warnScanSkipped). The per-leaf scan count is capped (matching the compose attachment limit) so a crafted .eml cannot amplify scan cost.

Configuration

See MTA System > ClamAV Sidecar for Docker setup and environment variables.

Extensibility

The scanner is built around three swappable seams so you can add rules or change backends without forking the package.

Content-Rule Registry

scanContent() no longer hard-calls each scanner. Instead it pre-processes the email once (HTML strip, URL extraction) into a ScanInput and iterates every rule installed in the contentRules registry (packages/email-scanner/src/content/rule.ts). The built-in rules — spam-keywords, phishing-urls, homoglyphs, caps-abuse, excessive-punctuation, prohibited-content — register themselves at module load, in that order, which is also the flag iteration order.

A ContentScanRule is a pure, synchronous function with a stable id:

import { registerContentRule, type ScanInput } from '@owlat/email-scanner';

registerContentRule({
  id: 'block-competitor-domains',
  scan(input: ScanInput) {
    const hit = input.urls.find((u) => u.href.includes('competitor.example'));
    return hit
      ? [{ type: 'suspicious_pattern', severity: 'medium', description: 'Competitor link', match: hit.href }]
      : [];
  },
});

Use unregisterContentRule(id) to remove one (returns true if a rule was removed). Both helpers are exported from the package entry. A rule that throws does not abort the scan — scanContent catches it, skips that rule, and appends a low-severity flag naming the failing rule id so a misbehaving plugin cannot silently drop legitimate flags from healthy rules.

Antivirus Provider

packages/email-scanner/src/clamav/provider.ts defines an AntivirusProvider facade (scan, ping, getProviderName) over the existing ClamAV client. getAntivirusProvider() reads the ANTIVIRUS_PROVIDER env var:

ValueBehavior
clamav (default)createClamAvProvider() — wraps the pooled clamd TCP client
noopcreateNoopAntivirusProvider() — every scan returns { clean: true, skipped: true }

URL-Reputation Provider

packages/email-scanner/src/urls/provider.ts defines a UrlReputationProvider facade (check, getProviderName). getUrlReputationProvider() reads the URL_REPUTATION_PROVIDER env var:

ValueBehavior
safe-browsing (default)createSafeBrowsingProvider() — Google Safe Browsing v4; with no API key it returns every URL as safe
noopcreateNoopUrlReputationProvider() — every URL returns safe
noop providers are fail-open

The noop antivirus and URL-reputation providers exist for local development and tests where running clamd or holding a Safe Browsing key is impractical. They always report clean, so never select them in production. The provider factories are not part of the package's public export surface — the barrel (@owlat/email-scanner) re-exports the content-rule helpers (registerContentRule/unregisterContentRule/contentRules) but not the provider factories, and the ./clamav and ./urls subpath entry points only re-export the underlying clients/utilities. Import the providers from the clamav/provider.ts and urls/provider.ts modules by relative path (as the package's own tests do).

Feedback Loops

Spam complaints from ISP feedback loops are linked back to campaign content scan results:

  1. A delivery webhook delivers a complaint event for a campaign send
  2. The complaint reducer in apps/api/convex/delivery/sendLifecycle/feedbackReducers.ts emits a content_scan_complaint effect, whose handler in apps/api/convex/delivery/sendLifecycle/effects.ts looks up the campaign's contentScanResults row (matched on resourceType: 'campaign')
  3. A low-severity suspicious_pattern flag — description "Spam complaint received (feedback loop)" — is appended to that row's flags array, with the complaining address recorded in the flag's match field
  4. Because the linkage is keyed on resourceType: 'campaign', only campaign content-scan results pick up complaint flags; transactional scans are not annotated this way

Integration Summary

ScannerWhere CalledBlocking?On Failure
Content (spam + homoglyphs)campaigns/send.ts, transactional/lifecycle.tsYesN/A (pure TS, always runs)
File type validationdelivery/worker.ts, mediaAssets.tsYesBlock (safe default)
URL reputation (Safe Browsing)campaigns/send.tsCampaigns: yes, Transactional: noAllow, skip silently
ClamAV malware scan (outbound)delivery/worker.ts via MTA /scan/attachmentYesAllow, log warning (fail-open)
ClamAV malware scan (inbound)mail/delivery.ts (scanInboundAttachments) via MTA /scan/attachmentInfected → routed to SpamSkip + warn (fail-open)

Package Structure

packages/email-scanner/src/
├── content/              # Content analysis (pure TS, Convex-safe)
│   ├── index.ts          # scanContent() — iterates the rule registry
│   ├── rule.ts           # contentRules registry + ScanInput/ContentScanRule types
│   ├── spamKeywords.ts   # ~40 weighted spam patterns
│   ├── phishingUrls.ts   # URL shorteners, anchor/href mismatch
│   ├── homoglyphs.ts     # Unicode spoofing detection
│   ├── prohibitedContent.ts  # Advance fee fraud, credential phishing
│   └── subjectAnalysis.ts    # ALL CAPS, excessive punctuation
├── files/                # File type validation (pure TS)
│   ├── index.ts          # validateFile() orchestrator
│   ├── magicBytes.ts     # Binary header detection
│   ├── doubleExtension.ts    # invoice.pdf.exe detection
│   └── filePolicy.ts     # Allowlist/blocklist engine
├── urls/                 # URL reputation (uses fetch)
│   ├── index.ts          # checkUrlReputation() orchestrator
│   ├── provider.ts       # UrlReputationProvider abstraction (URL_REPUTATION_PROVIDER)
│   ├── safeBrowsing.ts   # Google Safe Browsing API v4 client
│   └── cache.ts          # Abstract cache interface
├── clamav/               # ClamAV TCP client (Node.js net, MTA only)
│   ├── index.ts          # createClamClient() factory
│   ├── provider.ts       # AntivirusProvider abstraction (ANTIVIRUS_PROVIDER)
│   ├── client.ts         # clamd INSTREAM protocol implementation
│   └── pool.ts           # Connection pooling
├── types.ts              # Shared types (ContentFlag, ScanResult, etc.)
└── index.ts              # Barrel export (excludes clamav/)
ClamAV is Node.js only

The clamav/ module uses Node.js net for TCP connections and is only importable from the MTA. The content/, files/, and urls/ modules are pure TS and work in both Convex and Node.js environments.

Key Files

FilePurpose
packages/email-scanner/src/content/index.tsscanContent() — main content scanning orchestrator
packages/email-scanner/src/files/index.tsvalidateFile() — file validation orchestrator
packages/email-scanner/src/urls/index.tscheckUrlReputation() — URL reputation orchestrator
packages/email-scanner/src/clamav/index.tscreateClamClient() — ClamAV client factory
apps/api/convex/campaigns/send.tsContent scanning + URL reputation in the campaign send flow (imports scanContent/checkUrlReputation directly from @owlat/email-scanner)
apps/api/convex/transactional/lifecycle.tsContent scanning in the transactional published transition (scanContent only)
apps/api/convex/delivery/worker.tsAttachment validation + ClamAV scan before sending
apps/api/convex/mail/delivery.tsInbound (Postbox) attachment ClamAV scan on received mail (scanInboundAttachments)
apps/api/convex/mediaAssets.tsUpload file validation (extension + MIME type)
apps/api/convex/schema/delivery.tsurlReputationCache + contentScanResults table definitions
apps/api/convex/delivery/sendLifecycle/feedbackReducers.tsComplaint feedback loop (emits the content_scan_complaint effect)
apps/api/convex/delivery/sendLifecycle/effects.tsComplaint feedback loop (content_scan_complaint effect handler)
apps/mta/src/routes/scan.tsMTA /scan/attachment and /scan/health endpoints
apps/mta/docker-compose.ymlClamAV sidecar configuration