I’ve never run my own mail server before. Before today I had no clue
how email worked under the hood other than the very few times I’ve set
up mail clients.
I’ve heard no few times how hard it is to send mail from a
self-hosted server (because of spam filters). But how hard can it be
to hook up DNS to my personal server and receive email to my domain
sent from Gmail or another real-world client?
I knew it would be simpler to just send local mail to a local mail
server with a local mail client but that didn’t seem as real. If I
could send email from my Gmail account and receive it in my server I’d
be happy.
I spent the afternoon digging into this. All code is available on
Github. The “live stream” is in
the Multiprocess Discord‘s
#hacking-networks channel.
DNS
First I bought a domain. (I needed to be able to mess around with
records without blowing up anything important.)
I knew that MX records controlled where mail for a domain is sent. But
I had to look up the
specifics. You need to
create an MX record that points to an A or AAAA record. So you need
both an MX record and an A or AAAA record.
Done.
Firewall
The firewall on Fedora is aggressive. Gotta open up port 25.
$ sudo firewall-cmd --zone=dmz --add-port=25/tcp --permanent
$ sudo firewall-cmd --zone=public --add-port=25/tcp --permanent
$ sudo firewall-cmd --reload
I don’t understand what zones are here.
What protocols?
I knew that you send email with SMTP and you read it with POP3 or
IMAP. But it hadn’t clicked before that the mail server has to speak
SMTP and if you only ever read on the server (which is of course
impractical in the real world) you don’t need POP3 or IMAP.
So to meaningfully receive email from Gmail all I needed to do was implement SMTP.
SMTP
First I found the RFC for
SMTP (or one of them
anyway) and the wikipedia page for
it.
First off I’d need to run a TCP server on port 25.
package main
import (
"errors"
"log"
"net"
"strconv"
"strings"
)
func logError(err error) {
log.Printf("[ERROR] %sn", err)
}
func logInfo(msg string) {
log.Printf("[INFO] %sn", msg)
}
type message struct {
clientDomain string
smtpCommands map[string]string
atmHeaders map[string]string
body string
from string
date string
subject string
to string
}
type connection struct {
conn net.Conn
id int
buf []byte
}
// TODO
func (c *connection) handle() {
// TODO
}
func main() {
l, err := net.Listen("tcp", "0.0.0.0:25")
if err != nil {
panic(err)
}
defer l.Close()
logInfo("Listening")
id := 0
for {
conn, err := l.Accept()
if err != nil {
logError(err)
continue
}
id += 1
c := connection{conn, id, nil}
go c.handle()
}
}
Just a basic TCP server that passes off connections inside a
goroutine.
Greeting
After starting a connection, the server must send a greeting. The
successful greeting response code is 220
. It can optionally be
followed by additional text. Like most commands in SMTP it must be
ended with CRLF (rn
).
So we’ll add a helper function for writing lines that end in CRLF:
func (c *connection) writeLine(msg string) error {
msg += "rn"
for len(msg) > 0 {
n, err := c.conn.Write([]byte(msg))
if err != nil {
return err
}
msg = msg[n:]
}
return nil
}
And then we’ll send that 220
in the handle
function.
func (c *connection) handle() {
defer c.conn.Close()
c.logInfo("Connection accepted")
err := c.writeLine("220")
if err != nil {
c.logError(err)
return
}
c.logInfo("Awaiting EHLO")
// TODO
EHLO
Next we need to be able to read requests from the client. We’ll write
a helper that reads until the next CRLF. We’ll keep a buffer of unread
bytes in case we accidentally get bytes past the next CRLF. We’ll
store that buffer in the connection object.
func (c *connection) readLine() (string, error) {
for {
b := make([]byte, 1024)
n, err := c.conn.Read(b)
if err != nil {
return "", err
}
c.buf = append(c.buf, b[:n]...)
for i, b := range c.buf {
// If end of line
if b == 'n' && i > 0 && c.buf[i-1] == 'r' {