Version: 1.0 | Date: April 2026
Domain: mxwatch.app
Tagline: Email infrastructure monitoring for developers who run their own mail.
MxWatch is a self-hosted email infrastructure dashboard for developers and indie founders who run their own mail servers. It continuously monitors domain health, parses DMARC aggregate reports, watches IP/domain blacklists, tracks SPF/DKIM/DMARC record integrity, and alerts immediately when something breaks — before your email starts bouncing.
The problem it solves: Running your own email (Stalwart, Mailcow, Postfix, Maddy) is hard not because of the initial setup, but because of the ongoing invisible maintenance. Your IP silently hits a Spamhaus blacklist. Your DMARC reports are XML files nobody reads. Your SPF record drifts. You find out when a client emails asking why they're not getting your invoices.
The founder's unfair advantage: The founder (Darius) runs Stalwart Mail Server for 6 domains, migrated from Mailcow, cleared a Spamhaus PBL listing manually, built a WireGuard relay through RackNerd VPS, achieved 10/10 on mail-tester.com, and configures SPF/DKIM/DMARC for all domains. Every feature in this spec was needed personally.
Primary: Developers and indie founders running self-hosted mail (Stalwart, Mailcow, Postfix, Maddy, iRedMail)
Secondary: Small agencies managing email infrastructure for clients (2–20 domains)
Tertiary: Homelab enthusiasts with personal domains
Not targeting: Enterprise (EasyDMARC, PowerDMARC own that), cold email senders (MailDeck owns that), general businesses without technical users.
| Tier | Price | Domains | Features |
|---|---|---|---|
| Self-hosted | Free | Unlimited | Full feature set, community support |
| Cloud Solo | $9/mo | Up to 10 | Hosted, managed, email/Slack alerts |
| Cloud Teams | $29/mo | Unlimited | All Solo features + team members + API access |
Monetisation model: Open-source self-hosted core drives adoption. Cloud tier charges for convenience (no server needed, managed uptime, push alerts). This is the Plausible/Umami playbook applied to email infra.
- Framework: Next.js 15 (App Router) + TypeScript
- Styling: Tailwind CSS v4 + shadcn/ui
- State: TanStack Query (server state) + Zustand (UI state)
- Charts: Recharts
- Auth: better-auth with email/password + optional OAuth (GitHub, Google)
- API: tRPC (type-safe end-to-end, same pattern as Infra OS)
- Database: SQLite with Drizzle ORM (WAL mode, single file, zero ops)
- Background jobs: BullMQ with Redis (or node-cron for self-hosted simplicity)
- Email receiving: Custom SMTP listener on port 25 for DMARC
ruareports (or webhook-based parsing) - DNS lookups:
dnsNode.js native module +node-dnsfor custom resolvers
mxwatch/
├── apps/
│ └── web/ # Next.js app (UI + API routes)
│ ├── src/
│ │ ├── app/ # App Router pages
│ │ ├── components/ # UI components
│ │ ├── server/ # tRPC routers
│ │ └── lib/ # Utilities
├── packages/
│ ├── db/ # Drizzle schema + migrations
│ ├── monitor/ # Core monitoring logic (DNS, blacklists, DMARC)
│ ├── alerts/ # Alert channel implementations
│ └── types/ # Shared TypeScript types
├── docker-compose.yml
├── docker-compose.self-hosted.yml
└── CLAUDE.md
- Self-hosted: Single
docker compose up— one container (Next.js + SQLite) - Cloud: Railway or Fly.io, SQLite + Litestream for backup replication
- Environment variables:
DATABASE_URL,MXWATCH_SECRET,SMTP_PORT,ALERT_SMTP_*
// packages/db/schema.ts
// Users & Auth
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
passwordHash: text('password_hash'),
name: text('name'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
plan: text('plan', { enum: ['self_hosted', 'solo', 'teams'] }).default('self_hosted'),
})
// Domains being monitored
export const domains = sqliteTable('domains', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
domain: text('domain').notNull(), // e.g. "gitbay.dev"
addedAt: integer('added_at', { mode: 'timestamp' }).notNull(),
isActive: integer('is_active', { mode: 'boolean' }).default(true),
notes: text('notes'),
})
// DNS record snapshots — taken on each check cycle
export const dnsSnapshots = sqliteTable('dns_snapshots', {
id: text('id').primaryKey(),
domainId: text('domain_id').notNull().references(() => domains.id),
checkedAt: integer('checked_at', { mode: 'timestamp' }).notNull(),
spfRecord: text('spf_record'), // raw TXT value
spfValid: integer('spf_valid', { mode: 'boolean' }),
spfLookupCount: integer('spf_lookup_count'),// must be <= 10
dkimSelector: text('dkim_selector'), // e.g. "mail"
dkimRecord: text('dkim_record'),
dkimValid: integer('dkim_valid', { mode: 'boolean' }),
dmarcRecord: text('dmarc_record'),
dmarcPolicy: text('dmarc_policy', { enum: ['none', 'quarantine', 'reject'] }),
dmarcValid: integer('dmarc_valid', { mode: 'boolean' }),
mxRecords: text('mx_records'), // JSON array
healthScore: integer('health_score'), // 0-100 composite
})
// DKIM selectors per domain (can have multiple)
export const dkimSelectors = sqliteTable('dkim_selectors', {
id: text('id').primaryKey(),
domainId: text('domain_id').notNull().references(() => domains.id),
selector: text('selector').notNull(), // e.g. "mail", "dkim2026"
addedAt: integer('added_at', { mode: 'timestamp' }).notNull(),
})
// Blacklist checks
export const blacklistChecks = sqliteTable('blacklist_checks', {
id: text('id').primaryKey(),
domainId: text('domain_id').notNull().references(() => domains.id),
checkedAt: integer('checked_at', { mode: 'timestamp' }).notNull(),
ipAddress: text('ip_address'), // sending IP checked
listedOn: text('listed_on'), // JSON array of blacklist names
isListed: integer('is_listed', { mode: 'boolean' }),
})
// Blacklist definitions (the RBLs we check)
export const blacklists = sqliteTable('blacklists', {
id: text('id').primaryKey(),
name: text('name').notNull(), // "Spamhaus PBL"
host: text('host').notNull(), // "pbl.spamhaus.org"
isActive: integer('is_active', { mode: 'boolean' }).default(true),
severity: text('severity', { enum: ['critical', 'high', 'medium'] }),
removalUrl: text('removal_url'), // link to delist form
removalGuide: text('removal_guide'), // markdown instructions
})
// DMARC aggregate reports (parsed from XML)
export const dmarcReports = sqliteTable('dmarc_reports', {
id: text('id').primaryKey(),
domainId: text('domain_id').notNull().references(() => domains.id),
reportId: text('report_id').notNull(), // from report metadata
orgName: text('org_name').notNull(), // reporting org (Google, Yahoo, etc.)
dateRangeBegin: integer('date_range_begin', { mode: 'timestamp' }),
dateRangeEnd: integer('date_range_end', { mode: 'timestamp' }),
receivedAt: integer('received_at', { mode: 'timestamp' }).notNull(),
totalMessages: integer('total_messages').default(0),
passCount: integer('pass_count').default(0),
failCount: integer('fail_count').default(0),
rawXml: text('raw_xml'), // stored for re-parsing
})
// Individual DMARC report rows (one per source IP per report)
export const dmarcReportRows = sqliteTable('dmarc_report_rows', {
id: text('id').primaryKey(),
reportId: text('report_id').notNull().references(() => dmarcReports.id),
sourceIp: text('source_ip').notNull(),
count: integer('count').notNull(),
disposition: text('disposition'), // "none", "quarantine", "reject"
spfResult: text('spf_result'), // "pass", "fail", "softfail"
dkimResult: text('dkim_result'), // "pass", "fail"
headerFrom: text('header_from'),
})
// Alert rules per domain
export const alertRules = sqliteTable('alert_rules', {
id: text('id').primaryKey(),
domainId: text('domain_id').notNull().references(() => domains.id),
type: text('type', {
enum: ['blacklist_listed', 'dns_record_changed', 'dmarc_fail_spike', 'health_score_drop', 'dmarc_report_received']
}).notNull(),
threshold: integer('threshold'), // e.g. fail % threshold for spike
isActive: integer('is_active', { mode: 'boolean' }).default(true),
})
// Alert delivery channels per user
export const alertChannels = sqliteTable('alert_channels', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
type: text('type', { enum: ['email', 'slack', 'webhook', 'ntfy'] }).notNull(),
config: text('config').notNull(), // JSON: { url, token, etc. }
isActive: integer('is_active', { mode: 'boolean' }).default(true),
label: text('label'), // "Slack #alerts"
})
// Alert history
export const alertHistory = sqliteTable('alert_history', {
id: text('id').primaryKey(),
domainId: text('domain_id').notNull().references(() => domains.id),
ruleId: text('rule_id').references(() => alertRules.id),
firedAt: integer('fired_at', { mode: 'timestamp' }).notNull(),
type: text('type').notNull(),
message: text('message').notNull(),
resolvedAt: integer('resolved_at', { mode: 'timestamp' }), // null = still active
channelsSent: text('channels_sent'), // JSON array of channel IDs
})
// Check schedule config per domain
export const checkSchedules = sqliteTable('check_schedules', {
id: text('id').primaryKey(),
domainId: text('domain_id').notNull().references(() => domains.id).unique(),
dnsIntervalMinutes: integer('dns_interval_minutes').default(60),
blacklistIntervalMinutes: integer('blacklist_interval_minutes').default(360),
lastDnsCheck: integer('last_dns_check', { mode: 'timestamp' }),
lastBlacklistCheck: integer('last_blacklist_check', { mode: 'timestamp' }),
})export const BLACKLISTS = [
// Critical — major deliverability impact
{ name: 'Spamhaus ZEN', host: 'zen.spamhaus.org', severity: 'critical',
removalUrl: 'https://check.spamhaus.org/' },
{ name: 'Spamhaus PBL', host: 'pbl.spamhaus.org', severity: 'critical',
removalUrl: 'https://www.spamhaus.org/pbl/query/REMOVEPBL' },
{ name: 'Spamhaus SBL', host: 'sbl.spamhaus.org', severity: 'critical',
removalUrl: 'https://check.spamhaus.org/' },
{ name: 'Spamhaus DBL', host: 'dbl.spamhaus.org', severity: 'critical' },
{ name: 'Barracuda BRBL', host: 'b.barracudacentral.org', severity: 'critical',
removalUrl: 'https://www.barracudacentral.org/rbl/removal-request' },
{ name: 'SORBS DUHL', host: 'dul.sorbs.net', severity: 'high' },
{ name: 'SORBS SPAM', host: 'spam.sorbs.net', severity: 'high' },
{ name: 'Invaluement ivmSIP', host: 'sip.invaluement.com', severity: 'high' },
{ name: 'SpamCop', host: 'bl.spamcop.net', severity: 'high' },
{ name: 'UCEPROTECT L1', host: 'dnsbl-1.uceprotect.net', severity: 'medium' },
{ name: 'MXToolbox Top', host: 'dnsbl.mxtoolbox.com', severity: 'medium' },
{ name: 'Passive Spam Block', host: 'psbl.surriel.com', severity: 'medium' },
]
// DNSBL lookup: reverse IP + append blacklist host
// e.g. for IP 1.2.3.4 checking zen.spamhaus.org:
// lookup: 4.3.2.1.zen.spamhaus.org — A record present = listed
export async function checkIpAgainstBlacklist(ip: string, blacklist: typeof BLACKLISTS[0]) {
const reversed = ip.split('.').reverse().join('.')
const lookup = `${reversed}.${blacklist.host}`
try {
await dns.promises.resolve4(lookup)
return { listed: true, blacklist: blacklist.name }
} catch {
return { listed: false, blacklist: blacklist.name }
}
}export async function checkDomainHealth(domain: string, dkimSelectors: string[]) {
const results = {
spf: await checkSpf(domain),
dkim: await Promise.all(dkimSelectors.map(s => checkDkim(domain, s))),
dmarc: await checkDmarc(domain),
mx: await checkMx(domain),
}
return {
...results,
healthScore: calculateHealthScore(results),
}
}
async function checkSpf(domain: string) {
const txt = await dns.promises.resolveTxt(domain)
const spf = txt.flat().find(r => r.startsWith('v=spf1'))
if (!spf) return { valid: false, record: null, lookupCount: 0, issues: ['No SPF record found'] }
const lookupCount = countSpfLookups(spf) // count include/a/mx/ptr/exists mechanisms
const issues = []
if (lookupCount > 10) issues.push(`SPF exceeds 10 DNS lookup limit (${lookupCount} found)`)
if (spf.includes('+all')) issues.push('SPF uses +all — extremely dangerous, allows any sender')
if (!spf.includes('~all') && !spf.includes('-all')) issues.push('SPF missing ~all or -all qualifier')
return { valid: issues.length === 0, record: spf, lookupCount, issues }
}
async function checkDkim(domain: string, selector: string) {
try {
const txt = await dns.promises.resolveTxt(`${selector}._domainkey.${domain}`)
const record = txt.flat().join('')
const issues = []
if (record.includes('k=rsa') && !record.includes('p=')) issues.push('DKIM public key missing')
const keyMatch = record.match(/p=([A-Za-z0-9+/=]+)/)
if (keyMatch) {
const keyLength = Buffer.from(keyMatch[1], 'base64').length * 8
if (keyLength < 1024) issues.push(`DKIM key too short (${keyLength} bits, minimum 1024)`)
if (keyLength < 2048) issues.push(`DKIM key should be 2048 bits (currently ${keyLength})`)
}
return { selector, valid: issues.length === 0, record, issues }
} catch {
return { selector, valid: false, record: null, issues: [`DKIM selector '${selector}' not found`] }
}
}
async function checkDmarc(domain: string) {
try {
const txt = await dns.promises.resolveTxt(`_dmarc.${domain}`)
const record = txt.flat().join('')
const issues = []
const policy = record.match(/p=(none|quarantine|reject)/)?.[1]
if (!policy) issues.push('DMARC policy not set')
if (policy === 'none') issues.push('DMARC policy is p=none — emails not protected yet')
const hasRua = record.includes('rua=')
if (!hasRua) issues.push('No DMARC aggregate report address (rua) — you are flying blind')
const pct = record.match(/pct=(\d+)/)?.[1]
if (pct && parseInt(pct) < 100) issues.push(`DMARC pct=${pct} — policy only applies to ${pct}% of mail`)
return { valid: issues.length === 0, record, policy, hasRua, issues }
} catch {
return { valid: false, record: null, policy: null, hasRua: false,
issues: ['No DMARC record found'] }
}
}
function calculateHealthScore(results: ReturnType<typeof checkDomainHealth> extends Promise<infer T> ? T : never): number {
let score = 100
if (!results.spf.valid) score -= 25
else if (results.spf.issues.length) score -= 10
if (!results.dkim.some(d => d.valid)) score -= 25
if (!results.dmarc.valid) score -= 30
else if (results.dmarc.policy === 'none') score -= 10
if (!results.mx.length) score -= 20
return Math.max(0, score)
}import { XMLParser } from 'fast-xml-parser'
export function parseDmarcReport(xml: string) {
const parser = new XMLParser({ ignoreAttributes: false })
const result = parser.parse(xml)
const feedback = result.feedback
const metadata = feedback.report_metadata
const policyPublished = feedback.policy_published
const records = Array.isArray(feedback.record) ? feedback.record : [feedback.record]
return {
reportId: metadata.report_id,
orgName: metadata.org_name,
email: metadata.email,
dateRangeBegin: new Date(metadata.date_range.begin * 1000),
dateRangeEnd: new Date(metadata.date_range.end * 1000),
domain: policyPublished.domain,
policy: policyPublished.p,
rows: records.map(record => ({
sourceIp: record.row.source_ip,
count: record.row.count,
disposition: record.row.policy_evaluated?.disposition,
dkimResult: record.row.policy_evaluated?.dkim,
spfResult: record.row.policy_evaluated?.spf,
headerFrom: record.identifiers?.header_from,
})),
}
}DMARC aggregate reports arrive as email attachments (ZIP or GZIP containing XML).
import { SMTPServer } from 'smtp-server'
import { simpleParser } from 'mailparser'
import { createGunzip } from 'zlib'
import { createReadStream } from 'fs'
import AdmZip from 'adm-zip'
export function startSmtpListener(port: number, onReport: (xml: string, fromDomain: string) => Promise<void>) {
const server = new SMTPServer({
authOptional: true,
onData(stream, session, callback) {
simpleParser(stream, async (err, mail) => {
if (err) return callback(err)
for (const attachment of (mail.attachments || [])) {
let xml: string | null = null
if (attachment.contentType === 'application/zip' || attachment.filename?.endsWith('.zip')) {
const zip = new AdmZip(attachment.content)
xml = zip.getEntries()[0]?.getData().toString('utf8') ?? null
} else if (attachment.contentType === 'application/gzip' || attachment.filename?.endsWith('.gz')) {
xml = await gunzipBuffer(attachment.content)
} else if (attachment.filename?.endsWith('.xml')) {
xml = attachment.content.toString('utf8')
}
if (xml) {
const from = session.envelope.mailFrom?.address ?? 'unknown'
await onReport(xml, from).catch(console.error)
}
}
callback()
})
},
})
server.listen(port, () => console.log(`MxWatch SMTP listener on port ${port}`))
return server
}/ → Dashboard (overview of all domains)
/domains → Domain list + add domain
/domains/[id] → Domain detail view
/domains/[id]/dmarc → DMARC reports browser
/domains/[id]/blacklists → Blacklist check history
/domains/[id]/dns → DNS record history + diff
/domains/[id]/alerts → Alert rule config for this domain
/settings → User settings
/settings/alerts → Global alert channels (Slack, email, ntfy)
/settings/smtp → SMTP listener config (rua address setup)
/onboarding → First-run setup wizard
/login → Auth
/signup → Create account
The main dashboard shows:
- Health score cards — one per domain, coloured red/amber/green, with score 0-100
- Active alerts — any current blacklist listings or DNS issues, with "Fix this" CTAs
- Recent DMARC report summary — pass/fail ratio across all domains, last 7 days
- Last checked — when each domain was last scanned
Tabs:
- Overview — current health score breakdown, SPF/DKIM/DMARC status chips, sending IPs
- DMARC — report timeline chart, pass/fail ratio, source IP breakdown table
- Blacklists — grid of all monitored RBLs, green/red status, last check time
- DNS Records — current SPF/DKIM/DMARC values with inline issue flags, history diff
- Alerts — rules enabled for this domain
| Type | Trigger | Default |
|---|---|---|
blacklist_listed |
IP or domain appears on any monitored RBL | Enabled, critical severity |
dns_record_changed |
Any SPF/DKIM/DMARC record changes vs last snapshot | Enabled |
dmarc_fail_spike |
DMARC fail rate exceeds threshold (default 10%) | Enabled |
health_score_drop |
Health score drops more than 20 points | Enabled |
dmarc_report_received |
New aggregate report parsed | Optional |
// packages/alerts/channels.ts
export async function sendAlert(channel: AlertChannel, alert: Alert) {
switch (channel.type) {
case 'email':
return sendEmailAlert(channel.config, alert)
case 'slack':
return sendSlackAlert(channel.config.webhookUrl, alert)
case 'webhook':
return sendWebhookAlert(channel.config.url, alert)
case 'ntfy':
return sendNtfyAlert(channel.config.url, channel.config.topic, alert)
}
}
async function sendNtfyAlert(url: string, topic: string, alert: Alert) {
// ntfy.sh compatible — works with self-hosted ntfy too
await fetch(`${url}/${topic}`, {
method: 'POST',
headers: {
'Title': `MxWatch: ${alert.domainName}`,
'Priority': alert.severity === 'critical' ? 'urgent' : 'default',
'Tags': alert.type,
},
body: alert.message,
})
}First-run experience — critical for conversion. 4 steps:
Step 1: Add your first domain
- Enter domain name
- Immediate DNS check runs in background while they fill in step 2
Step 2: Configure DMARC reporting
- Show the exact DNS TXT record to add as
_dmarc.yourdomain.com - The
rua=mailto:address points to MxWatch' SMTP listener or a provided mailbox - Self-hosted:
rua=mailto:dmarc@mxwatch.yourdomain.com(configure MX) - Cloud:
rua=mailto:reports@in.mxwatch.app
Step 3: Add DKIM selectors
- Enter selector names (e.g. "mail", "dkim2026")
- MxWatch checks if they resolve, shows found/not found
Step 4: Set up alerts
- Pick email address for alerts
- Optional: Slack webhook or ntfy topic
- "Send test alert" button
Every issue flagged by MxWatch has an inline "Fix this →" button that opens a drawer with:
- Plain-English explanation of what the issue is and why it matters
- Exact DNS record to add or change (copyable)
- Verification command (e.g.
dig TXT _dmarc.yourdomain.com) - For blacklist listings: step-by-step removal guide specific to that blacklist
Examples:
- "Your SPF record has 12 DNS lookups (max is 10)" → show which mechanisms to flatten using
include:substitution - "Listed on Spamhaus PBL" → explain it's a residential IP policy, link to removal form, explain WireGuard relay workaround
- "DMARC policy is p=none" → explain the three stages (none → quarantine → reject), suggest moving to quarantine
- Auth (email/password, single user)
- Add/remove domains
- DNS health check: SPF, DKIM (single selector), DMARC
- Health score calculation
- Blacklist monitoring: top 8 critical/high RBLs
- DMARC report ingestion via SMTP listener (port 2525 for self-hosted, port 25 needs root)
- DMARC report parsing and visualisation (pass/fail chart, source IP table)
- Alert channels: email only
- Basic "Fix this" copy for common issues
- Dashboard + domain detail pages
- Docker Compose deploy (single container)
- Onboarding wizard (simplified 2-step)
- Multiple DKIM selectors per domain
- Slack + ntfy + webhook alerts
- Full blacklist suite (12 RBLs)
- DNS record diff history
- DMARC fail spike detection
- Cloud deploy (Railway)
- Stripe billing for cloud tier
- Settings page (alert channels, check intervals)
- IP warm-up scheduler
- Google Postmaster Tools integration (OAuth)
- Team members (Teams plan)
- API access (read-only)
- Export reports (CSV, PDF)
- Stalwart/Mailcow native log parsing
Day 1 of V1 completion: add all 6 domains.
| Domain | Stalwart? | Current score | Notes |
|---|---|---|---|
| gitbay.dev | Yes | Unknown | Primary product |
| homelabza.com | Yes | 10/10 mail-tester | Solid baseline |
| nudgenudge.com | Yes | Unknown | |
| uno-post.com | Yes | Unknown | |
| igotreceipts.app | Yes | Unknown | |
| packetdeck.com | Yes | Unknown |
DMARC rua for all 6 domains will point to the local SMTP listener at 192.168.69.12:2525 (Stalwart's address), or via a Cloudflare tunnel to the MxWatch instance.
{
"dependencies": {
"next": "^15.0.0",
"better-auth": "^1.0.0",
"drizzle-orm": "^0.30.0",
"better-sqlite3": "^9.0.0",
"@trpc/server": "^11.0.0",
"@trpc/client": "^11.0.0",
"@trpc/next": "^11.0.0",
"smtp-server": "^3.13.0",
"mailparser": "^3.6.0",
"fast-xml-parser": "^4.3.0",
"adm-zip": "^0.5.16",
"nodemailer": "^6.9.0",
"recharts": "^2.12.0",
"tailwindcss": "^4.0.0",
"zod": "^3.22.0",
"bullmq": "^5.0.0",
"ioredis": "^5.3.0"
},
"devDependencies": {
"drizzle-kit": "^0.20.0",
"typescript": "^5.4.0"
}
}# MxWatch
Email infrastructure monitoring dashboard for developers running self-hosted mail.
## Stack
- Next.js 15 + TypeScript (App Router)
- tRPC for type-safe API
- Drizzle ORM + SQLite (WAL mode)
- better-auth for authentication
- Tailwind CSS v4 + shadcn/ui
- BullMQ + Redis for background jobs
## Dev Setup
\`\`\`bash
npm install
cp .env.example .env
npm run db:push # apply schema
npm run dev
\`\`\`
## Key Conventions
- All API routes go through tRPC routers in `apps/web/src/server/routers/`
- Database access only via Drizzle — no raw SQL
- All monitoring logic lives in `packages/monitor/` — keep it framework-agnostic
- Alert channels in `packages/alerts/` — each channel is a separate file
- Use `nanoid()` for all IDs
- Timestamps stored as Unix integers (mode: 'timestamp')
- Never store raw credentials — encrypt alert channel configs with `MXWATCH_SECRET`
## Database
\`\`\`bash
npm run db:push # push schema changes
npm run db:studio # open Drizzle Studio
npm run db:migrate # run migrations
\`\`\`
## Background Jobs
SMTP listener starts on `SMTP_PORT` (default 2525).
DNS + blacklist checks scheduled via BullMQ workers.
For self-hosted without Redis: fall back to `node-cron` in-process scheduler.
## Environment Variables
- `DATABASE_URL` — path to SQLite file (default: `./mxwatch.db`)
- `MXWATCH_SECRET` — 32-char secret for encrypting stored credentials
- `SMTP_PORT` — port for DMARC report SMTP listener (default: 2525)
- `NEXT_PUBLIC_APP_URL` — public URL of the app
- `REDIS_URL` — optional, for BullMQ (falls back to in-process cron)
## Testing Blacklist Checks Locally
\`\`\`bash
# Test a known-listed IP (Spamhaus test IPs)
curl http://localhost:3000/api/test-blacklist?ip=127.0.0.2
\`\`\`Copy this verbatim into a Claude Code session in the mxwatch project root:
You are building MxWatch — an email infrastructure monitoring dashboard for developers who run self-hosted mail servers.
Read the full spec in mxwatch-spec.md before writing any code.
Start with the following tasks in order:
1. Initialise the monorepo with the structure defined in the spec:
- apps/web (Next.js 15 + TypeScript)
- packages/db (Drizzle schema)
- packages/monitor (DNS + blacklist logic)
- packages/alerts (alert channels)
- packages/types (shared types)
2. Set up the database schema exactly as defined in the spec's schema section. Use Drizzle with better-sqlite3.
3. Implement the core monitoring logic:
- packages/monitor/dns.ts — checkSpf, checkDkim, checkDmarc, calculateHealthScore
- packages/monitor/blacklists.ts — BLACKLISTS constant + checkIpAgainstBlacklist
- packages/monitor/dmarc-parser.ts — parseDmarcReport using fast-xml-parser
4. Set up tRPC routers for:
- domains (CRUD)
- checks (trigger + get results)
- reports (DMARC report list + detail)
- alerts (rules + history)
5. Build the UI pages in this order:
- /login and /signup (better-auth)
- /onboarding (2-step wizard: add domain + set alert email)
- / dashboard (domain health cards, active alerts)
- /domains/[id] (detail with tabs: Overview, DMARC, Blacklists, DNS)
6. Set up the SMTP listener in packages/monitor/smtp-listener.ts
7. Create docker-compose.yml for self-hosted deployment (single container)
Use shadcn/ui components throughout. All forms validated with Zod. Health scores use coloured indicators: <50 red, 50-79 amber, 80+ green.
Ask me before making architectural decisions not covered in the spec.
Week 1–2: Build V1, dogfood on 6 domains
Week 3: Fix issues found in dogfooding, write the launch blog post
Week 4: Launch
Launch blog post title: "How I achieved 10/10 on mail-tester.com and cleared a Spamhaus PBL listing (and built a tool so you don't have to do it manually)"
Launch channels:
- r/selfhosted — "Show r/selfhosted: I built MxWatch, an open-source email infra dashboard because I got tired of checking Spamhaus manually"
- r/homelab
- Hacker News Show HN
- Stalwart Discord / community
- Mailcow community forums
- homelabza.com blog post
First 30 days goal: 500 self-hosted installs, 20 paying cloud subscribers ($180 MRR) 3 month goal: 50 cloud subscribers ($450 MRR), product paying for its own hosting