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 weight | Individual 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.combut links toevil.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 LatinaU+0061, GreekοU+03BF vs Latino, 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 cardnumbers) - Credential phishing (
confirm/verify/update/resetpaired withpassword/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:
| Severity | Points | Example |
|---|---|---|
| High | 20 | Homoglyph spoofing, credential phishing, high-weight spam keywords |
| Medium | 10 | URL shorteners, medium-weight spam keywords, combined low-pattern aggregate |
| Low | 3 | Excessive punctuation, ALL CAPS subject |
Thresholds:
| Score | Level | Action |
|---|---|---|
| 0–14 | Clean | Allowed |
| 15–39 | Suspicious | Allowed with warning stored |
| 40+ | Blocked | Send 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 Type | Magic Bytes |
|---|---|
| PE executable (.exe, .dll) | 4D 5A (MZ) — dangerous, blocked |
| ELF binary | 7F 45 4C 46 — dangerous, 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 installer | D0 CF 11 E0 (OLE compound) — dangerous, blocked |
| ISO 9660 disk image | 43 44 30 30 31 (CD001) at offset 0x8001 — dangerous, blocked |
25 50 44 46 (%PDF) | |
| PNG | 89 50 4E 47 |
| JPEG | FF D8 FF |
| ZIP/DOCX/XLSX | 50 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 blockedreport.docx.js— detected and blockedphoto.jpg.scr— detected and blocked
Extension Allowlist
Permitted file types (everything else is blocked):
| Category | Extensions |
|---|---|
| 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 sendingmediaAssets.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
- Extract all URLs from the email HTML content
- Normalize and hash URLs (SHA-256)
- Check cache (
urlReputationCachetable) for known verdicts - Batch-check uncached URLs against Safe Browsing API (up to 500 per request)
- Cache results (24h for clean, 1h for flagged)
- Convert flagged URLs to
ContentFlagentries —maliciousverdicts map to high severity,suspiciousverdicts to medium (urlReputationToFlags)
Threat Types
| Threat | Description |
|---|---|
MALWARE | Sites hosting malicious software |
SOCIAL_ENGINEERING | Phishing and deceptive sites |
UNWANTED_SOFTWARE | Sites distributing unwanted software |
POTENTIALLY_HARMFUL_APPLICATION | Mobile app threats |
Campaign vs Transactional
| Email Type | Behavior |
|---|---|
| Campaign sends | Blocking gate — checkUrlReputation runs in campaigns/send.ts and flagged URLs prevent the campaign from sending |
| Transactional sends | Not 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:
- Send
zINSTREAM\0 - Send chunked binary data (4-byte big-endian length prefix + data)
- Send zero-length terminator
- Read verdict:
stream: OK\0orstream: <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:
| Value | Behavior |
|---|---|
clamav (default) | createClamAvProvider() — wraps the pooled clamd TCP client |
noop | createNoopAntivirusProvider() — 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:
| Value | Behavior |
|---|---|
safe-browsing (default) | createSafeBrowsingProvider() — Google Safe Browsing v4; with no API key it returns every URL as safe |
noop | createNoopUrlReputationProvider() — every URL returns safe |
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:
- A delivery webhook delivers a
complaintevent for a campaign send - The complaint reducer in
apps/api/convex/delivery/sendLifecycle/feedbackReducers.tsemits acontent_scan_complainteffect, whose handler inapps/api/convex/delivery/sendLifecycle/effects.tslooks up the campaign'scontentScanResultsrow (matched onresourceType: 'campaign') - A low-severity
suspicious_patternflag — description"Spam complaint received (feedback loop)"— is appended to that row'sflagsarray, with the complaining address recorded in the flag'smatchfield - 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
| Scanner | Where Called | Blocking? | On Failure |
|---|---|---|---|
| Content (spam + homoglyphs) | campaigns/send.ts, transactional/lifecycle.ts | Yes | N/A (pure TS, always runs) |
| File type validation | delivery/worker.ts, mediaAssets.ts | Yes | Block (safe default) |
| URL reputation (Safe Browsing) | campaigns/send.ts | Campaigns: yes, Transactional: no | Allow, skip silently |
| ClamAV malware scan (outbound) | delivery/worker.ts via MTA /scan/attachment | Yes | Allow, log warning (fail-open) |
| ClamAV malware scan (inbound) | mail/delivery.ts (scanInboundAttachments) via MTA /scan/attachment | Infected → routed to Spam | Skip + 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/)
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
| File | Purpose |
|---|---|
packages/email-scanner/src/content/index.ts | scanContent() — main content scanning orchestrator |
packages/email-scanner/src/files/index.ts | validateFile() — file validation orchestrator |
packages/email-scanner/src/urls/index.ts | checkUrlReputation() — URL reputation orchestrator |
packages/email-scanner/src/clamav/index.ts | createClamClient() — ClamAV client factory |
apps/api/convex/campaigns/send.ts | Content scanning + URL reputation in the campaign send flow (imports scanContent/checkUrlReputation directly from @owlat/email-scanner) |
apps/api/convex/transactional/lifecycle.ts | Content scanning in the transactional published transition (scanContent only) |
apps/api/convex/delivery/worker.ts | Attachment validation + ClamAV scan before sending |
apps/api/convex/mail/delivery.ts | Inbound (Postbox) attachment ClamAV scan on received mail (scanInboundAttachments) |
apps/api/convex/mediaAssets.ts | Upload file validation (extension + MIME type) |
apps/api/convex/schema/delivery.ts | urlReputationCache + contentScanResults table definitions |
apps/api/convex/delivery/sendLifecycle/feedbackReducers.ts | Complaint feedback loop (emits the content_scan_complaint effect) |
apps/api/convex/delivery/sendLifecycle/effects.ts | Complaint feedback loop (content_scan_complaint effect handler) |
apps/mta/src/routes/scan.ts | MTA /scan/attachment and /scan/health endpoints |
apps/mta/docker-compose.yml | ClamAV sidecar configuration |