Initial import of Hugo site to Forgejo

This commit is contained in:
Iman Alipour 2026-07-28 12:47:20 +00:00
commit cb1ba0317f
1259 changed files with 236349 additions and 0 deletions

View file

@ -0,0 +1,666 @@
<!DOCTYPE html>
<html lang="en" dir="auto">
<head><meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="robots" content="noindex, nofollow">
<title>Self-hosting mail the hard way (Postfix &#43; Dovecot &#43; PostfixAdmin &#43; Roundcube, and zero Mailcow) | AlipourIm journeys</title>
<meta name="keywords" content="mail, postfix, dovecot, opendkim, postfixadmin, roundcube, nginx, self-hosting, homelab, dns, dkim, spf, dmarc">
<meta name="description" content="A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again).">
<meta name="author" content="Iman Alipour">
<link rel="canonical" href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/">
<link crossorigin="anonymous" href="/assets/css/stylesheet.31b150d909186c48d6dbbf653acc2489945fa9ac2c8f8cd3fe8448e89e40fccf.css" integrity="sha256-MbFQ2QkYbEjW279lOswkiZRfqawsj4zT/oRI6J5A/M8=" rel="preload stylesheet" as="style">
<link rel="icon" href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/favicon.ico">
<link rel="icon" type="image/png" sizes="16x16" href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/favicon-16x16.png">
<link rel="icon" type="image/png" sizes="32x32" href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/favicon-32x32.png">
<link rel="apple-touch-icon" href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/apple-touch-icon.png">
<link rel="mask-icon" href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/safari-pinned-tab.svg">
<meta name="theme-color" content="#2e2e33">
<meta name="msapplication-TileColor" content="#2e2e33">
<link rel="alternate" hreflang="en" href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/">
<noscript>
<style>
#theme-toggle,
.top-link {
display: none;
}
</style>
<style>
@media (prefers-color-scheme: dark) {
:root {
--theme: rgb(29, 30, 32);
--entry: rgb(46, 46, 51);
--primary: rgb(218, 218, 219);
--secondary: rgb(155, 156, 157);
--tertiary: rgb(65, 66, 68);
--content: rgb(196, 196, 197);
--code-block-bg: rgb(46, 46, 51);
--code-bg: rgb(55, 56, 62);
--border: rgb(51, 51, 51);
}
.list {
background: var(--theme);
}
.list:not(.dark)::-webkit-scrollbar-track {
background: 0 0;
}
.list:not(.dark)::-webkit-scrollbar-thumb {
border-color: var(--theme);
}
}
</style>
</noscript><script>
(function () {
window.goatcounter = { endpoint: "/count" };
const s = document.createElement("script");
s.async = true;
s.src = "/js/count.js";
document.head.appendChild(s);
})();
</script>
<script>
(function () {
const GC = "";
const nf = new Intl.NumberFormat();
const SING = "visit";
const PLUR = "visits";
function ready(fn) {
if (document.readyState === "complete" || document.readyState === "interactive") setTimeout(fn, 0);
else document.addEventListener("DOMContentLoaded", fn);
}
function gcPath() {
try {
return window.goatcounter && window.goatcounter.get_data
? window.goatcounter.get_data().p
: location.pathname;
} catch (_) {
return location.pathname;
}
}
async function fetchCount(path, period) {
try {
const q = new URLSearchParams({ t: String(Date.now()) });
if (period) q.set("start", period);
const url = `${GC}/counter/${encodeURIComponent(path)}.json?${q}`;
const r = await fetch(url, { credentials: "omit" });
if (r.status === 404) return 0;
if (!r.ok) throw 0;
const j = await r.json();
const n = Number(String(j.count).replace(/,/g, ""));
return Number.isFinite(n) ? n : 0;
} catch {
return null;
}
}
const labelVisits = (n) =>
n === null ? "—" : `${nf.format(n)} ${n === 1 ? SING : PLUR}`;
ready(async function () {
const post = document.querySelector(".post-single");
if (post) {
const meta = post.querySelector(
".post-header .post-meta, .post-header .entry-meta, .post-meta"
);
const visits = await fetchCount(gcPath());
if (meta) {
let span = meta.querySelector(".post-views");
if (!span) {
span = document.createElement("span");
span.className = "post-views";
meta.appendChild(span);
}
span.textContent = labelVisits(visits ?? 0);
}
}
const cards = document.querySelectorAll("article.post-entry");
await Promise.all(
Array.from(cards).map(async (card) => {
const meta = card.querySelector(".entry-footer, .post-meta");
const link = card.querySelector("a.entry-link, h2 a, .entry-title a");
if (!meta || !link) return;
try {
const u = new URL(link.getAttribute("href"), location.origin);
if (u.origin !== location.origin) return;
let span = meta.querySelector(".post-views");
if (!span) {
span = document.createElement("span");
span.className = "post-views";
meta.appendChild(span);
}
span.textContent = labelVisits((await fetchCount(u.pathname)) ?? 0);
} catch {}
})
);
const uvEl = document.getElementById("gc_site_visitors_week");
if (uvEl) {
const n = await fetchCount("TOTAL", "week");
uvEl.textContent = n === null ? "—" : nf.format(n);
}
});
})();
</script>
</head>
<body class="" id="top">
<script>
if (localStorage.getItem("pref-theme") === "dark") {
document.body.classList.add('dark');
} else if (localStorage.getItem("pref-theme") === "light") {
document.body.classList.remove('dark')
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.body.classList.add('dark');
}
</script>
<header class="header">
<nav class="nav">
<div class="logo">
<a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/" accesskey="h" title="AlipourIm journeys (Alt + H)">AlipourIm journeys</a>
<div class="logo-switches">
<button id="theme-toggle" accesskey="t" title="(Alt + T)" aria-label="Toggle theme">
<svg id="moon" xmlns="http://www.w3.org/2000/svg" width="24" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
<svg id="sun" xmlns="http://www.w3.org/2000/svg" width="24" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
</button>
</div>
</div>
<ul id="menu">
<li>
<a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/" title="Posts">
<span>Posts</span>
</a>
</li>
<li>
<a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/" title="PhD_journey">
<span>PhD_journey</span>
</a>
</li>
<li>
<a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/about/" title="About">
<span>About</span>
</a>
</li>
</ul>
</nav>
</header>
<main class="main">
<article class="post-single">
<header class="post-header">
<div class="breadcrumbs"><a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/">Home</a>&nbsp;»&nbsp;<a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/">Posts</a></div>
<h1 class="post-title entry-hint-parent">
Self-hosting mail the hard way (Postfix &#43; Dovecot &#43; PostfixAdmin &#43; Roundcube, and zero Mailcow)
</h1>
<div class="post-description">
A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again).
</div>
<div class="post-meta"><span title='2026-07-24 00:00:00 +0000 UTC'>July 24, 2026</span>&nbsp;·&nbsp;17 min&nbsp;·&nbsp;Iman Alipour
<span class="post-meta-item">
<a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/#isso-thread" class="isso-comments-link">Comments</a>
</span>
</div>
</header>
<div class="post-content"><blockquote>
<p>If you came here scared of mail servers: good. That means youre paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do <strong>not</strong> assume you already know what an MX record is, why port 587 exists, or why Cloudflares orange cloud and SMTP should never meet at a party.</p></blockquote>
<p>I wanted real email on my own machine — <code>mail.alipourimjourneys.ir</code> — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)</p>
<p>By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when Im too lazy for a desktop client. And I finally understand <em>why</em> those pieces exist, which is the part the shiny installers quietly skip.</p>
<hr>
<h2 id="why-i-bothered">Why I bothered<a hidden class="anchor" aria-hidden="true" href="#why-i-bothered">#</a></h2>
<p>Funbox already hosted a small zoo of services with nice names and Lets Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.</p>
<p>I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldnt be guessing which logo to Google. Doing it by hand is slower. Its also how you stop treating mail as magic.</p>
<hr>
<h2 id="how-email-actually-moves">How email actually moves<a hidden class="anchor" aria-hidden="true" href="#how-email-actually-moves">#</a></h2>
<p>Imagine a post office that both receives letters from other post offices <em>and</em> lets you drop off letters to send — but only if you show ID at the counter.</p>
<p><strong>Postfix</strong> is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domains mail instructions in DNS, finds your server, and knocks on port <strong>25</strong>. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).</p>
<p>When <em>you</em> want to send mail from Thunderbird or Roundcube, you dont casually open port 25 from a laptop anymore. You use <strong>submission</strong>, usually port <strong>587</strong>, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.</p>
<p><strong>Dovecot</strong> is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port <strong>993</strong> so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.</p>
<p><strong>OpenDKIM</strong> sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesnt encrypt the love letter for privacy; it helps prove the letter wasnt casually forged by a random stranger using your From address.</p>
<p>Then theres the paperwork in DNS — SPF, the DKIM public key, DMARC — which isnt software running on your machine. Its instructions you publish so other peoples servers know how to judge mail that claims to be from you.</p>
<p>Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.</p>
<hr>
<h2 id="the-shape-of-my-setup">The shape of my setup<a hidden class="anchor" aria-hidden="true" href="#the-shape-of-my-setup">#</a></h2>
<p>On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under <code>/srv/services/mail/</code> runs Postgres only for that mail world, listening on <code>127.0.0.1:5433</code>. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.</p>
<p>Nginx terminates HTTPS for a little portal at <code>mail.alipourimjourneys.ir</code>, the admin UI at <code>mailadmin.alipourimjourneys.ir</code>, and Roundcube at <code>webmail.alipourimjourneys.ir</code>. Clients still talk to <code>mail.alipourimjourneys.ir</code> for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.</p>
<hr>
<h2 id="port-25-is-for-servers-port-587-is-for-people">Port 25 is for servers; port 587 is for people<a hidden class="anchor" aria-hidden="true" href="#port-25-is-for-servers-port-587-is-for-people">#</a></h2>
<p>This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.</p>
<p>Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed <em>to your domain</em>, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.</p>
<p>Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.</p>
<p>I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesnt instantly mean youre an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldnt even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.</p>
<hr>
<h2 id="what-an-open-relay-is-and-why-localhost-lies-to-you">What an “open relay” is, and why localhost lies to you<a hidden class="anchor" aria-hidden="true" href="#what-an-open-relay-is-and-why-localhost-lies-to-you">#</a></h2>
<p>An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domains reputation.</p>
<p>Heres the trick that wastes afternoons: testing from <code>127.0.0.1</code>.</p>
<p>Postfix treats localhost as friendly. Cron jobs, monitoring, local <code>sendmail</code> calls — they need to inject mail without performing a full human login. That friendliness is encoded in <code>mynetworks</code>. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.</p>
<p>So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from <code>evil@example.com</code> to <code>someone@example.com</code>, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of <code>454 Relay access denied</code>. A cheerful <code>250</code> on that recipient line is how you wake up as someone elses cannon.</p>
<p>Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”</p>
<hr>
<h2 id="dns-but-make-it-human">DNS, but make it human<a hidden class="anchor" aria-hidden="true" href="#dns-but-make-it-human">#</a></h2>
<h3 id="the-forward-map-name--number">The forward map (name → number)<a hidden class="anchor" aria-hidden="true" href="#the-forward-map-name--number">#</a></h3>
<p>You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an <strong>MX</strong> record, which is simply a polite note: “if youre delivering mail for this domain, knock on <em>that</em> hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.</p>
<p>I put <code>mail.alipourimjourneys.ir</code> on funbox for both v4 and v6, pointed MX at that name, and added <code>webmail</code> and <code>mailadmin</code> the same way.</p>
<h3 id="why-cloudflares-orange-cloud-is-not-invited">Why Cloudflares orange cloud is not invited<a hidden class="anchor" aria-hidden="true" href="#why-cloudflares-orange-cloud-is-not-invited">#</a></h3>
<p>Cloudflares proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend its a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.</p>
<p>Mail hostnames stay <strong>DNS-only</strong> — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.</p>
<hr>
<h2 id="ptr-records-the-phonebook-in-reverse">PTR records: the phonebook in reverse<a hidden class="anchor" aria-hidden="true" href="#ptr-records-the-phonebook-in-reverse">#</a></h2>
<p>This is the part that confused me the longest the first time I met it years ago, so Im going to walk slowly.</p>
<p>Normal DNS is a phonebook from names to numbers: you look up <code>mail.example.com</code> and learn the IP. <strong>Reverse DNS</strong> is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a <strong>PTR</strong> record.</p>
<p>Those reverse records do <em>not</em> live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for <code>alipourimjourneys.ir</code> does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.</p>
<p>So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to <code>mail.alipourimjourneys.ir</code>, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel WiFi.</p>
<p>You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.</p>
<hr>
<h2 id="spf-dkim-and-dmarc-three-different-questions">SPF, DKIM, and DMARC: three different questions<a hidden class="anchor" aria-hidden="true" href="#spf-dkim-and-dmarc-three-different-questions">#</a></h2>
<p>People mash these together into “email authentication.” Theyre related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.</p>
<p><strong>SPF</strong> answers: “which machines are allowed to send mail claiming this domain?” Its a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this servers v4, this servers v6, and then <code>-all</code>, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If youre mid-migration and scared, a softer <code>~all</code> exists for a reason. I wasnt mid-migration. I was building one source of truth.</p>
<p><strong>DKIM</strong> answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they cant produce a valid stamp.</p>
<p><strong>DMARC</strong> answers: “if SPF/DKIM dont align properly with the visible From domain, what should receivers do?” You can start with <code>p=none</code> (watch and learn), move to <code>quarantine</code> (bad mail goes to spam), and later <code>reject</code> when youre brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean Im not pretending that “nearby” domains are close enough.</p>
<p>Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.</p>
<p>Together they dont make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.</p>
<hr>
<h2 id="building-the-unglamorous-core">Building the unglamorous core<a hidden class="anchor" aria-hidden="true" href="#building-the-unglamorous-core">#</a></h2>
<p>On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Lets Encrypt certificate for <code>mail.alipourimjourneys.ir</code>, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I dont need a cleartext nostalgia port in 2026.</p>
<p>Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under <code>/var/spool/postfix</code>, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the <code>postfix</code> user connect.</p>
<p>I briefly had two <code>Socket</code> lines in <code>opendkim.conf</code>. Config files dont merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: <code>DKIM-Signature field added</code>. Instant serotonin.</p>
<p>When <code>opendkim-testkey</code> says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.</p>
<hr>
<h2 id="the-matrix-certificate-heist-that-wasnt-a-heist">The Matrix certificate heist (that wasnt a heist)<a hidden class="anchor" aria-hidden="true" href="#the-matrix-certificate-heist-that-wasnt-a-heist">#</a></h2>
<p>Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting <code>mail.…</code> and found <strong><code>matrix.alipourimjourneys.ir</code></strong> smiling back.</p>
<p>No attacker. No cosmic joke from Lets Encrypt. Just nginx.</p>
<p>When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no <code>server_name</code> matches, nginx shrugs and uses the <strong>default</strong> server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrixs identity and fails the “are you who you say you are?” check in the most confusing way possible.</p>
<p>Later I repeated a sibling mistake with <code>webmail.</code>: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbots nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting <code>https://webmail.…</code> still fell through to the default server and received Matrixs cert again. Same villain, new episode.</p>
<p>The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.</p>
<hr>
<h2 id="from-linux-users-to-real-mailboxes">From Linux users to real mailboxes<a hidden class="anchor" aria-hidden="true" href="#from-linux-users-to-real-mailboxes">#</a></h2>
<p>The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.</p>
<p>So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under <code>/var/vmail/...</code> owned by a dedicated <code>vmail</code> user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.</p>
<p>Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.</p>
<p>One setting decides whether this feels cursed or calm: <code>mydestination</code>.</p>
<p>That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is <code>mail.alipourimjourneys.ir</code> and you also leave that name in <code>mydestination</code>, Postfix may keep trying to deliver like its 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set <code>mydestination</code> to localhost and let the mail domain be virtual. Suddenly the model matched reality.</p>
<p>PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”</p>
<p>One more client quirk: some apps send only <code>ialipour</code> as the username. The database stores <code>ialipour@mail.…</code>. Dovecots “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didnt save me the way I hoped. Teaching the SQL queries to accept either the full address or <code>local-part@mail.alipourimjourneys.ir</code> did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.</p>
<hr>
<h2 id="webmail-without-losing-the-plot">Webmail without losing the plot<a hidden class="anchor" aria-hidden="true" href="#webmail-without-losing-the-plot">#</a></h2>
<p>Roundcube is not a second mail server. Its a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.</p>
<p>I flirted with serving it under a path like <code>/webmail</code> on the main mail hostname while also serving it at the root of <code>webmail.…</code>. Roundcube has opinions about its base path. If you tell it “you live under <code>/webmail</code>” and then put it at <code>/</code> on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.</p>
<p>The calm ending: Roundcube lives at <code>https://webmail.alipourimjourneys.ir/</code> as the site root. The portal links there. Path experiments go in the diary under “character development.”</p>
<hr>
<h2 id="the-bounce-emails-that-taught-me-about-identity">The bounce emails that taught me about identity<a hidden class="anchor" aria-hidden="true" href="#the-bounce-emails-that-taught-me-about-identity">#</a></h2>
<p>After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail <code>root</code>. My system identity still thought the local domain was <code>mail.alipourimjourneys.ir</code>, so <code>root</code> became <code>root@mail.alipourimjourneys.ir</code>. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.</p>
<p>The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased <code>root</code> to my real virtual address so necessary noise still reaches a human. <code>myhostname</code> can still announce itself as <code>mail.…</code> on the SMTP banner. Different knobs, different jobs.</p>
<p>Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash cant be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving <code>vmail</code> ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.</p>
<p>And fail2ban exists because a public mail servers address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in <code>/var/log/mail.log</code> so repeated guessing gets a timeout instead of an unlimited hobby.</p>
<hr>
<h2 id="what-done-means-for-me">What “done” means for me<a hidden class="anchor" aria-hidden="true" href="#what-done-means-for-me">#</a></h2>
<p>For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at <code>mail.alipourimjourneys.ir</code>, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.</p>
<p>Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”</p>
<p>If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. Youre not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.</p>
<hr>
<h3 id="links">Links<a hidden class="anchor" aria-hidden="true" href="#links">#</a></h3>
<ul>
<li>Portal: <a href="https://mail.alipourimjourneys.ir">mail.alipourimjourneys.ir</a></li>
<li>Webmail: <a href="https://webmail.alipourimjourneys.ir">webmail.alipourimjourneys.ir</a></li>
<li>Admin: <a href="https://mailadmin.alipourimjourneys.ir">mailadmin.alipourimjourneys.ir</a></li>
</ul>
<hr>
<div class="signature-block" style="margin-top:1rem">
<p><strong>Downloads:</strong>
<a href="/sources/posts/self_hosting_mail.md">Markdown</a> ·
<a href="/sources/posts/self_hosting_mail.md.asc">Signature (.asc)</a>
</p><details class="signature">
<summary>View OpenPGP signature</summary>
<pre style="font-size:0.85em; overflow-x:auto; padding:0.75rem;">-----BEGIN PGP SIGNATURE-----
iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
jSo2Yw//UtI5N8jeF&#43;LTvsVHqDbTVyD&#43;x6qiMgRGT4Kpn86V&#43;6NaVjrs6h&#43;kc5Xy
TD9gzCfpwsyfSDBGQ2SHM&#43;bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
HhcRfnS1ICk&#43;hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
4kgsu5qV7bhB2IyS6nYb&#43;A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
4BAMhpgk4baTz0&#43;fSjWiKSokQhizXvB6vK21qu2O&#43;5WbkyY/LLi0klLlF2UqhKnU
HE3&#43;dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx&#43;Ec6&#43;nHiwtLu
O&#43;8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
0k4KDrEbfIvoowTRRXmNpHV&#43;ttSYanjzTl3oQQZ&#43;Y9H&#43;g&#43;uPrfh8oUvivrj&#43;2ZOn
iv9oMoW6Q3rB7V/2&#43;jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
=KCRU
-----END PGP SIGNATURE-----
</pre>
</details><p><em>Verify locally:</em></p>
<pre style="font-size:0.85em; overflow-x:auto; padding:0.75rem;">torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md
torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md.asc
gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
</pre>
</div>
</div>
<footer class="post-footer">
<ul class="post-tags">
<li><a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/mail/">Mail</a></li>
<li><a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/postfix/">Postfix</a></li>
<li><a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/dovecot/">Dovecot</a></li>
<li><a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/opendkim/">Opendkim</a></li>
<li><a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/postfixadmin/">Postfixadmin</a></li>
<li><a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/roundcube/">Roundcube</a></li>
<li><a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/nginx/">Nginx</a></li>
<li><a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/self-hosting/">Self-Hosting</a></li>
<li><a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/homelab/">Homelab</a></li>
<li><a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/dns/">Dns</a></li>
<li><a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/dkim/">Dkim</a></li>
<li><a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/spf/">Spf</a></li>
<li><a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/dmarc/">Dmarc</a></li>
</ul>
<nav class="paginav">
<a class="next" href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/june_2026/">
<span class="title">Next »</span>
<br>
<span>June 2026</span>
</a>
</nav>
</footer><div class="comments-switch" style="display:flex;gap:.5rem;margin:.5rem 0 1rem;">
<button id="btn-isso" type="button" aria-pressed="true">Anonymous (Isso)</button>
<button id="btn-giscus" type="button" aria-pressed="false">GitHub (Giscus)</button>
<a href="https://github.com/AlipourIm/blog-comments/discussions/categories/comments" target="_blank" rel="noopener">Open on GitHub ↗</a>
</div>
<div id="panel-giscus" style="display:none">
<section id="giscus-thread"></section>
</div>
<div id="panel-isso">
<section id="isso-thread"
data-isso-id="/posts/self_hosting_mail/"
data-title="Self-hosting mail the hard way (Postfix &#43; Dovecot &#43; PostfixAdmin &#43; Roundcube, and zero Mailcow)">
<noscript>Javascript needs to be activated to view comments.</noscript>
</section>
<script
src="/isso/js/embed.min.js"
data-isso="/isso/"
data-isso-css="false"
data-isso-lang="en"
data-isso-max-comments-nested="5"
data-isso-sorting="newest"
async>
</script>
<small class="isso-powered" style="display:block;margin:.5rem 0;color:var(--secondary,#888)">
Comments powered by <a href="https://isso-comments.de" target="_blank" rel="noopener">Isso</a>
</small>
</div>
<script>
(function () {
var panelIsso = document.getElementById('panel-isso');
var panelGisc = document.getElementById('panel-giscus');
var issoBtn = document.getElementById('btn-isso');
var giscBtn = document.getElementById('btn-giscus');
var gLoaded = false;
function isDark() {
var pref = localStorage.getItem('pref-theme');
if (pref === 'dark') return true;
if (pref === 'light') return false;
var html = document.documentElement, body = document.body;
if (
(html.classList && html.classList.contains('dark')) ||
(body.classList && body.classList.contains('dark')) ||
html.getAttribute('data-theme') === 'dark' ||
body.getAttribute('data-theme') === 'dark' ||
(html.classList && html.classList.contains('theme-dark')) ||
(body.classList && body.classList.contains('theme-dark'))
) return true;
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
}
function gTheme(){ return isDark() ? 'dark_dimmed' : 'noborder_light'; }
function loadGiscus() {
if (gLoaded) return;
var s = document.createElement('script');
s.src = 'https://giscus.app/client.js';
s.async = true;
s.crossOrigin = 'anonymous';
s.setAttribute('data-repo','AlipourIm/blog-comments');
s.setAttribute('data-repo-id','R_kgDOQGARyA');
s.setAttribute('data-category','Comments');
s.setAttribute('data-category-id','DIC_kwDOQGARyM4Cw3x-');
s.setAttribute('data-mapping','pathname');
s.setAttribute('data-strict','0');
s.setAttribute('data-reactions-enabled','1');
s.setAttribute('data-emit-metadata','0');
s.setAttribute('data-input-position','bottom');
s.setAttribute('data-lang','en');
s.setAttribute('data-theme', gTheme());
document.getElementById('giscus-thread').appendChild(s);
function reTheme(){
var iframe = document.querySelector('iframe.giscus-frame');
if (iframe && iframe.contentWindow) {
iframe.contentWindow.postMessage(
{ giscus: { setConfig: { theme: gTheme() } } },
'https://giscus.app'
);
}
}
var tbtn = document.getElementById('theme-toggle');
if (tbtn) tbtn.addEventListener('click', function(){ setTimeout(reTheme, 0); });
var mo = new MutationObserver(function(){ reTheme(); });
mo.observe(document.documentElement, { attributes:true, attributeFilter:['class','data-theme'] });
mo.observe(document.body, { attributes:true, attributeFilter:['class','data-theme'] });
gLoaded = true;
}
function show(which) {
var showIsso = which === 'isso';
panelIsso.style.display = showIsso ? 'block' : 'none';
panelGisc.style.display = showIsso ? 'none' : 'block';
if (issoBtn) issoBtn.setAttribute('aria-pressed', showIsso ? 'true' : 'false');
if (giscBtn) giscBtn.setAttribute('aria-pressed', showIsso ? 'false' : 'true');
if (!showIsso) loadGiscus();
}
if (issoBtn) issoBtn.addEventListener('click', function(e){ e.preventDefault(); show('isso'); });
if (giscBtn) giscBtn.addEventListener('click', function(e){ e.preventDefault(); show('giscus'); });
show('isso');
})();
</script>
</article>
</main>
<footer class="footer">
<span>&copy; 2026 <a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/">AlipourIm journeys</a></span> ·
<span>
Powered by
<a href="https://gohugo.io/" rel="noopener noreferrer" target="_blank">Hugo</a> &
<a href="https://github.com/adityatelange/hugo-PaperMod/" rel="noopener" target="_blank">PaperMod</a>
</span>
</footer>
<a href="#top" aria-label="go to top" title="Go to Top (Alt + G)" class="top-link" id="top-link" accesskey="g">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 6" fill="currentColor">
<path d="M12 6H0l6-6z" />
</svg>
</a>
<div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
<a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M4 11a9 9 0 0 1 9 9" />
<path d="M4 4a16 16 0 0 1 16 16" />
<circle cx="5" cy="19" r="1" />
</svg>
<span>RSS</span>
</a>
<span aria-hidden="true">·</span>
<span>Visitors this week: <span id="gc_site_visitors_week"></span></span>
</div>
<script>
let menu = document.getElementById('menu')
if (menu) {
menu.scrollLeft = localStorage.getItem("menu-scroll-position");
menu.onscroll = function () {
localStorage.setItem("menu-scroll-position", menu.scrollLeft);
}
}
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener("click", function (e) {
e.preventDefault();
var id = this.getAttribute("href").substr(1);
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
document.querySelector(`[id='${decodeURIComponent(id)}']`).scrollIntoView({
behavior: "smooth"
});
} else {
document.querySelector(`[id='${decodeURIComponent(id)}']`).scrollIntoView();
}
if (id === "top") {
history.replaceState(null, null, " ");
} else {
history.pushState(null, null, `#${id}`);
}
});
});
</script>
<script>
var mybutton = document.getElementById("top-link");
window.onscroll = function () {
if (document.body.scrollTop > 800 || document.documentElement.scrollTop > 800) {
mybutton.style.visibility = "visible";
mybutton.style.opacity = "1";
} else {
mybutton.style.visibility = "hidden";
mybutton.style.opacity = "0";
}
};
</script>
<script>
document.getElementById("theme-toggle").addEventListener("click", () => {
if (document.body.className.includes("dark")) {
document.body.classList.remove('dark');
localStorage.setItem("pref-theme", 'light');
} else {
document.body.classList.add('dark');
localStorage.setItem("pref-theme", 'dark');
}
})
</script>
<script>
document.querySelectorAll('pre > code').forEach((codeblock) => {
const container = codeblock.parentNode.parentNode;
const copybutton = document.createElement('button');
copybutton.classList.add('copy-code');
copybutton.innerHTML = 'copy';
function copyingDone() {
copybutton.innerHTML = 'copied!';
setTimeout(() => {
copybutton.innerHTML = 'copy';
}, 2000);
}
copybutton.addEventListener('click', (cb) => {
if ('clipboard' in navigator) {
navigator.clipboard.writeText(codeblock.textContent);
copyingDone();
return;
}
const range = document.createRange();
range.selectNodeContents(codeblock);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
try {
document.execCommand('copy');
copyingDone();
} catch (e) { };
selection.removeRange(range);
});
if (container.classList.contains("highlight")) {
container.appendChild(copybutton);
} else if (container.parentNode.firstChild == container) {
} else if (codeblock.parentNode.parentNode.parentNode.parentNode.parentNode.nodeName == "TABLE") {
codeblock.parentNode.parentNode.parentNode.parentNode.parentNode.appendChild(copybutton);
} else {
codeblock.parentNode.appendChild(copybutton);
}
});
</script>
</body>
</html>