Create Hetzner Account & VPS
Hetzner is a German cloud provider with excellent pricing for VPS instances. We need a small server to run our SMTP daemon 24/7.
- Go to hetzner.com/cloud and create an account
- Create a new project (e.g., “Mailens”)
- Click “Add Server” and configure:
| Location | Falkenstein or Helsinki (low latency to EU) |
| Image | Ubuntu 24.04 |
| Type | CX22 — 2 vCPU, 4 GB RAM (~€4.50/mo) |
| SSH Key | Add your public key (required) |
| Name | mailens-smtp |
Note your IP address
After the server is created, copy its IPv4 address. You'll need it for DNS records and SSH. We'll refer to it as <VPS_IP> throughout this guide.
If you don't have an SSH key yet, generate one on your local machine:
ssh-keygen -t ed25519 -C "your-email@example.com"
cat ~/.ssh/id_ed25519.pub # Copy this into HetznerInitial Server Setup
SSH into your brand new VPS and harden it. This takes about 5 minutes and keeps your server secure.
ssh root@<VPS_IP>Update the system and install essentials:
apt update && apt upgrade -y
apt install -y ufw fail2ban unattended-upgrades curl gitCreate a non-root user with sudo access:
adduser mailens
usermod -aG sudo mailens
# Copy SSH key to the new user
mkdir -p /home/mailens/.ssh
cp /root/.ssh/authorized_keys /home/mailens/.ssh/
chown -R mailens:mailens /home/mailens/.ssh
chmod 700 /home/mailens/.ssh
chmod 600 /home/mailens/.ssh/authorized_keysConfigure the firewall — we need ports 22 (SSH), 25 (SMTP), 80 & 443 (HTTP/HTTPS for Let's Encrypt):
ufw allow 22/tcp
ufw allow 25/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
ufw statusEnable automatic security updates:
dpkg-reconfigure -plow unattended-upgradesFrom now on, use the mailens user
Log out and reconnect as ssh mailens@<VPS_IP> for the rest of this guide. Use sudo when you need root.
Install Node.js & Haraka
Haraka is a high-performance SMTP server written in Node.js. It's plugin-based — perfect for our custom email processing pipeline.
Install Node.js 20 via NodeSource:
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
node --version # Should show v20.xInstall Haraka globally and initialize the Mailens SMTP directory:
sudo npm install -g Haraka
sudo haraka -i /opt/mailens-smtpThis creates the following structure:
/opt/mailens-smtp/
├── config/ # All configuration files
│ ├── smtp.ini # SMTP server settings
│ ├── host_list # Domains we accept mail for
│ ├── plugins # Plugin load order
│ └── ...
├── plugins/ # Custom plugins go here
├── docs/
└── package.jsonWhy Haraka?
Haraka handles 1000+ concurrent connections, has built-in SPF/DNSBL checking, and its plugin system lets us write custom JavaScript to validate Lens handles and store emails in Supabase. It's battle-tested by Craigslist.
Configure Haraka
Three config files need editing. These tell Haraka which port to listen on, which domain to accept mail for, and which plugins to run.
smtp.ini — SMTP server settings:
[main]
port=25
listen=0.0.0.0
nodes=2
; Connection limits
max_received_count=10
max_line_length=512
max_data_line_length=992
; Greeting
me=mail.mailens.xyzhost_list — domains we accept email for:
mailens.xyzplugins — the plugin pipeline. Order matters! Each email flows through these in sequence:
# Anti-spam checks (run first)
spf
dnsbl
# Validate headers
data.headers
# Custom: validate recipient is a real Lens handle
rcpt_to.mailens
# Custom: parse email and store in database
queue/mailensPlugin pipeline explained
When an email arrives, Haraka runs each plugin in order. spf checks if the sender is authorized, dnsbl checks IP blocklists, data.headers validates email headers. Then our custom rcpt_to.mailens plugin checks if the recipient is a real Lens handle, and finally queue/mailens parses and stores the email.
Write the Custom Plugins
This is the heart of Mailens. We write two Haraka plugins: one to validate recipients and one to store emails in the database.
First, install the dependencies inside the Haraka directory:
cd /opt/mailens-smtp
sudo npm install mailparser pg dotenvCreate a .env file for database credentials:
DATABASE_URL=postgresql://postgres:<password>@db.<project-ref>.supabase.co:5432/postgresPassword characters
Your Supabase database password must not contain special characters (or they must be URL-encoded). Simplest fix: go to Supabase Settings → Database and reset the password to something alphanumeric only.
Plugin 1: rcpt_to.mailens.js
This plugin runs when someone sends an email to an @mailens.xyz address. It checks if the Lens handle actually exists and rejects emails to fake handles or reserved addresses.
'use strict';
require('dotenv').config({ path: '/opt/mailens-smtp/.env' });
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// Reserved addresses that can't be used as Lens handles
const RESERVED = new Set([
'admin', 'administrator', 'postmaster', 'hostmaster', 'webmaster',
'abuse', 'noreply', 'no-reply', 'support', 'help', 'info', 'contact',
'security', 'mailer-daemon', 'mail', 'root', 'nobody', 'system',
'newsletter', 'marketing', 'billing', 'sales',
]);
exports.hook_rcpt = async function (next, connection, params) {
const rcpt = params[0];
const address = rcpt.address().toLowerCase();
const [localPart, domain] = address.split('@');
// Only handle our domain
if (domain !== 'mailens.xyz') {
return next();
}
// Block reserved addresses
if (RESERVED.has(localPart)) {
connection.loginfo(this, `Rejected reserved address: ${address}`);
return next(DENY, `550 <${address}>: Recipient address reserved`);
}
try {
// Check if user exists in our database
const result = await pool.query(
'SELECT lens_handle FROM mailens_users WHERE LOWER(lens_handle) = $1',
[localPart]
);
if (result.rows.length > 0) {
// Known user — accept immediately
connection.transaction.notes.lensHandle = result.rows[0].lens_handle;
connection.loginfo(this, `Accepted recipient: ${address}`);
return next(OK);
}
// User not in DB — they might be a valid Lens handle who hasn't
// logged in yet. Accept the email; queue/mailens will create a
// placeholder user record.
connection.transaction.notes.lensHandle = localPart;
connection.loginfo(this, `New handle, accepting: ${address}`);
return next(OK);
} catch (err) {
connection.logerror(this, `DB error: ${err.message}`);
// Temporary failure — tell sender to retry later
return next(DENYSOFT, '450 Temporary failure, please retry');
}
};Plugin 2: queue/mailens.js
This plugin fires after all checks pass. It parses the raw email with mailparser, extracts all fields, and inserts the email into Supabase.
'use strict';
require('dotenv').config({ path: '/opt/mailens-smtp/.env' });
const { simpleParser } = require('mailparser');
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
exports.hook_queue = function (next, connection) {
const txn = connection.transaction;
const lensHandle = txn.notes.lensHandle;
if (!lensHandle) {
return next(DENY, '550 No valid recipient');
}
// Collect the email stream into a buffer
const chunks = [];
txn.message_stream.on('data', (chunk) => chunks.push(chunk));
txn.message_stream.on('end', async () => {
try {
const raw = Buffer.concat(chunks);
const parsed = await simpleParser(raw);
const fromAddress = parsed.from?.value?.[0]?.address || 'unknown';
const fromName = parsed.from?.value?.[0]?.name || null;
const toAddress = parsed.to?.value?.[0]?.address || `${lensHandle}@mailens.xyz`;
// Generate a snippet (first ~120 chars of plain text)
const snippet = parsed.text
? parsed.text.replace(/\s+/g, ' ').trim().slice(0, 120)
: null;
await pool.query(
`INSERT INTO mailens_emails (
lens_handle, direction, from_address, from_name,
to_address, subject, body_text, body_html,
snippet, message_id, in_reply_to, received_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,NOW())`,
[
lensHandle,
'inbound',
fromAddress,
fromName,
toAddress,
parsed.subject || '(no subject)',
parsed.text || null,
parsed.html || null,
snippet,
parsed.messageId || null,
parsed.inReplyTo || null,
]
);
connection.loginfo(
'queue/mailens',
`Stored email for ${lensHandle} from ${fromAddress}`
);
return next(OK);
} catch (err) {
connection.logerror('queue/mailens', `Failed to store: ${err.message}`);
return next(DENYSOFT, '450 Temporary storage failure');
}
});
};Create the queue directory
Don't forget to create the plugins/queue/ subdirectory before saving the file: sudo mkdir -p /opt/mailens-smtp/plugins/queue
DNS Configuration
DNS records tell the internet where to deliver email for mailens.xyz. Go to your domain registrar (e.g. Namecheap) and add these records:
| Type | Host | Value | Why |
|---|---|---|---|
| A | <VPS_IP> | Points mail.mailens.xyz to your server | |
| MX | @ | mail.mailens.xyz (priority 10) | Routes emails to your SMTP server |
| TXT | @ | v=spf1 ip4:<VPS_IP> include:send.resend.com -all | SPF — authorizes your VPS + Resend to send |
| TXT | _dmarc | v=DMARC1; p=quarantine; rua=mailto:admin@mailens.xyz | DMARC — policy for failed auth checks |
What do these records do?
A record: Maps a hostname to an IP address. mail.mailens.xyz → your VPS.
MX record: “Mail eXchange” — tells other email servers where to deliver mail for @mailens.xyz.
SPF: “Sender Policy Framework” — lists which IPs are allowed to send email on behalf of mailens.xyz.
DMARC: Tells receiving servers what to do when SPF/DKIM checks fail (quarantine = send to spam).
DNS propagation
DNS changes can take up to 48 hours to propagate, but usually it's 5–30 minutes. Use dig MX mailens.xyz to check.
Reverse DNS (PTR Record)
A PTR record is the reverse of an A record — it maps an IP address back to a hostname. Many email servers reject mail from IPs without proper reverse DNS. This is set in Hetzner, not your domain registrar.
- Go to your Hetzner Cloud Console → select your server
- Click Networking tab
- Find your Primary IPv4 address and click the reverse DNS edit icon
- Set the value to:
mail.mailens.xyz
This is critical
Without reverse DNS, Gmail, Outlook, and most major providers will reject or spam-folder your outbound emails. Don't skip this step!
Verify it works from your VPS:
dig -x <VPS_IP> +short
# Should return: mail.mailens.xyz.Resend Domain Verification
Resend handles our outbound emails (when Lens users send from the web app). We need to verify domain ownership so Resend can sign emails with DKIM.
- Go to resend.com/domains and click “Add Domain”
- Enter
mailens.xyz - Resend will show you DNS records to add (usually 2–3 CNAME records for DKIM + a TXT for SPF/verification)
- Add those records to your domain registrar (Namecheap)
- Back in Resend, click “Verify” — it can take a few minutes
Resend + our SPF record coexist
The SPF record from Step 6 already includes include:send.resend.com — so outbound emails via Resend will pass SPF checks. The DKIM records Resend provides are separate CNAME entries that don't conflict with anything.
Run as a Systemd Service
We want Haraka to start automatically on boot and restart if it crashes. Systemd handles this perfectly.
Create an environment file for secrets (keeps them out of the service file):
sudo tee /etc/mailens-smtp.env << 'EOF'
DATABASE_URL=postgresql://postgres:<password>@db.<project-ref>.supabase.co:5432/postgres
EOF
sudo chmod 600 /etc/mailens-smtp.envCreate the systemd service file:
[Unit]
Description=Mailens SMTP Server (Haraka)
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/mailens-smtp
EnvironmentFile=/etc/mailens-smtp.env
ExecStart=/usr/bin/haraka -c /opt/mailens-smtp
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/opt/mailens-smtp
[Install]
WantedBy=multi-user.targetEnable and start the service:
sudo systemctl daemon-reload
sudo systemctl enable mailens-smtp
sudo systemctl start mailens-smtp
# Check it's running
sudo systemctl status mailens-smtpView the logs in real-time:
sudo journalctl -u mailens-smtp -fIf it fails to start
Check the logs with journalctl -u mailens-smtp --no-pager -n 50. Common issues: port 25 already in use (disable Postfix: sudo systemctl disable postfix), wrong file permissions, or missing Node modules.
Test End-to-End
Time for the magic moment! Let's verify everything works from the sending side all the way to the Mailens inbox.
Test 1: swaks (SMTP Swiss Army Knife)
Install swaks on your local machine (or any machine with internet access):
# macOS
brew install swaks
# Ubuntu/Debian
sudo apt install swaksSend a test email directly to your server:
swaks \
--to yourhandle@mailens.xyz \
--from test@gmail.com \
--server <VPS_IP> \
--port 25 \
--header "Subject: Hello from swaks!" \
--body "If you see this in the database, everything works!"What to look for
swaks should show 250 OK at the end. If you see a 450 or 550, check the Haraka logs.
Test 2: Check the database
# From your VPS, query Supabase directly:
sudo -u mailens psql "$DATABASE_URL" -c \
"SELECT id, lens_handle, from_address, subject, received_at
FROM mailens_emails ORDER BY received_at DESC LIMIT 5;"Test 3: Send from Gmail
Open Gmail (or any email client) and send an email to yourhandle@mailens.xyz. Wait 10–30 seconds, then check the database or refresh the Mailens web app. Your email should appear in the inbox!
Test 4: Check the web app
Log in at mailens.xyz with your Lens account. If Supabase Realtime is configured, the email should appear automatically. Otherwise, refresh the page.
Troubleshooting checklist
If emails aren't arriving:
- Check Haraka logs:
journalctl -u mailens-smtp -n 100 - Verify MX record:
dig MX mailens.xyz - Verify port 25 is open:
telnet <VPS_IP> 25 - Check UFW:
sudo ufw status - Make sure Hetzner firewall rules (Cloud Console) allow port 25 inbound
Monitoring & Maintenance
Your SMTP server is running! Here's how to keep an eye on it going forward.
UptimeRobot (free)
Set up a port monitor at uptimerobot.com to check that port 25 on your VPS is responding. The free tier checks every 5 minutes and sends email/SMS alerts on downtime.
- Create an account at UptimeRobot
- Add a new monitor → Type: Port
- Set host to
<VPS_IP>and port to25 - Set monitoring interval to 5 minutes
Useful commands
# Live Haraka logs
sudo journalctl -u mailens-smtp -f
# Last 100 log lines
sudo journalctl -u mailens-smtp --no-pager -n 100
# Service status
sudo systemctl status mailens-smtp
# Restart Haraka
sudo systemctl restart mailens-smtp
# Check disk usage
df -h
# Check email count in database
psql "$DATABASE_URL" -c "SELECT COUNT(*) FROM mailens_emails;"
# Check if port 25 is listening
ss -tlnp | grep :25You did it!
Your Mailens SMTP server is live! Anyone on the internet can now send an email to handle@mailens.xyz and it will land in the Mailens inbox. Time to show the Lens team what you built.
Built with care for the Lens community.
Back to Mailens