Back to home
Phase 1.5~45 min

Hetzner Setup Guide

Go from zero to receiving real emails at handle@mailens.xyz. We'll set up a Hetzner VPS, install Haraka (a Node.js SMTP server), configure DNS, and test end-to-end.

01

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.

  1. Go to hetzner.com/cloud and create an account
  2. Create a new project (e.g., “Mailens”)
  3. Click “Add Server” and configure:
LocationFalkenstein or Helsinki (low latency to EU)
ImageUbuntu 24.04
TypeCX22 — 2 vCPU, 4 GB RAM (~€4.50/mo)
SSH KeyAdd your public key (required)
Namemailens-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:

Local machinebash
ssh-keygen -t ed25519 -C "your-email@example.com"
cat ~/.ssh/id_ed25519.pub  # Copy this into Hetzner
02

Initial Server Setup

SSH into your brand new VPS and harden it. This takes about 5 minutes and keeps your server secure.

Local machinebash
ssh root@<VPS_IP>

Update the system and install essentials:

VPS (root)bash
apt update && apt upgrade -y
apt install -y ufw fail2ban unattended-upgrades curl git

Create a non-root user with sudo access:

VPS (root)bash
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_keys

Configure the firewall — we need ports 22 (SSH), 25 (SMTP), 80 & 443 (HTTP/HTTPS for Let's Encrypt):

VPS (root)bash
ufw allow 22/tcp
ufw allow 25/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
ufw status

Enable automatic security updates:

VPS (root)bash
dpkg-reconfigure -plow unattended-upgrades

From 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.

03

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:

VPSbash
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
node --version  # Should show v20.x

Install Haraka globally and initialize the Mailens SMTP directory:

VPSbash
sudo npm install -g Haraka
sudo haraka -i /opt/mailens-smtp

This 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.json

Why 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.

04

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:

config/smtp.iniini
[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.xyz

host_list — domains we accept email for:

config/host_listtext
mailens.xyz

plugins — the plugin pipeline. Order matters! Each email flows through these in sequence:

config/pluginstext
# 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/mailens

Plugin 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.

05

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:

VPSbash
cd /opt/mailens-smtp
sudo npm install mailparser pg dotenv

Create a .env file for database credentials:

/opt/mailens-smtp/.envbash
DATABASE_URL=postgresql://postgres:<password>@db.<project-ref>.supabase.co:5432/postgres

Password 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.

plugins/rcpt_to.mailens.jsjavascript
'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.

plugins/queue/mailens.jsjavascript
'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

06

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:

TypeHostValueWhy
Amail<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 -allSPF — authorizes your VPS + Resend to send
TXT_dmarcv=DMARC1; p=quarantine; rua=mailto:admin@mailens.xyzDMARC — 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.

07

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.

  1. Go to your Hetzner Cloud Console → select your server
  2. Click Networking tab
  3. Find your Primary IPv4 address and click the reverse DNS edit icon
  4. 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:

VPSbash
dig -x <VPS_IP> +short
# Should return: mail.mailens.xyz.
08

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.

  1. Go to resend.com/domains and click “Add Domain”
  2. Enter mailens.xyz
  3. Resend will show you DNS records to add (usually 2–3 CNAME records for DKIM + a TXT for SPF/verification)
  4. Add those records to your domain registrar (Namecheap)
  5. 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.

09

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):

VPSbash
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.env

Create the systemd service file:

/etc/systemd/system/mailens-smtp.serviceini
[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.target

Enable and start the service:

VPSbash
sudo systemctl daemon-reload
sudo systemctl enable mailens-smtp
sudo systemctl start mailens-smtp

# Check it's running
sudo systemctl status mailens-smtp

View the logs in real-time:

VPSbash
sudo journalctl -u mailens-smtp -f

If 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.

10

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):

Local machinebash
# macOS
brew install swaks

# Ubuntu/Debian
sudo apt install swaks

Send a test email directly to your server:

Local machinebash
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

VPSbash
# 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
11

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.

  1. Create an account at UptimeRobot
  2. Add a new monitor → Type: Port
  3. Set host to <VPS_IP> and port to 25
  4. Set monitoring interval to 5 minutes

Useful commands

Useful VPS commandsbash
# 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 :25

You 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