Build Your Own SMTP Server in Go
Sharing what we learned about building Ferdinand, our emails sending service.
At Valyent, we are building open-source software for developers. And email, among others, is a subject that haunted us for a while. So we decided to learn as much as we can on the subject, in a hurry.
Email infrastructure relies on several key protocols, with the most important being:
SMTP (Simple Mail Transfer Protocol): Used for sending and receiving emails between mail servers.
IMAP (Internet Message Access Protocol): Allows users to read and manage emails directly from the server.
POP3 (Post Office Protocol version 3): Downloads emails from the server to the local device, typically removing them from the server.
In today’s article, we're going to focus on building our own outbound SMTP server, mirroring the approach we've taken with Ferdinand. By doing so, we'll gain a deep understanding of the most crucial component in email sending infrastructure.
"What I cannot create, I do not understand."
— Richard Feynman
By building an outbound SMTP server from scratch, you can gain a level of insight into email delivery that most developers never achieve.
To proceed, we are going to use the Go programming language, along with the awesome mail librairies from Simon Ser. We'll demystify the process, show you how to send emails to other servers, and even explain key concepts like SPF, DKIM, and DMARC allowing for deliverability.
By the end, you'll have at least have a deeper understanding of email infrastructure, despite not having a production-ready SMTP server.
Understanding SMTP: The Basics
Before we dive into the code, let's review what SMTP is and how it works. SMTP (Simple Mail Transfer Protocol) is the standard protocol for sending email across the Internet. It's a relatively simple, text-based protocol that operates on a client-server model.
SMTP Commands
The SMTP protocol makes use of commands. Each command in SMTP serves a specific purpose in the email transmission process. They allow servers to introduce themselves, specify senders and recipients, transfer the actual email content, and manage the overall communication session. Think of these commands as a structured conversation between two email servers, where each command represents a specific statement or question in that conversation.
When you build an SMTP server, you're essentially creating a program that can speak this language fluently, interpreting incoming commands and responding appropriately, as well as issuing the right commands when sending emails.
Let's explore the most important SMTP commands to see how this conversation unfolds:
HELO/EHLO (Hello): This command initiates the SMTP conversation. EHLO is the extended SMTP version, supporting additional features. The syntax is
HELO domain
orEHLO domain
. For example:EHLO example.com
.MAIL FROM: This command specifies the sender's email address and starts a new mail transaction. It uses the syntax
MAIL FROM:<sender@example.com>
. An example would beMAIL FROM:<john@example.com>
.RCPT TO: Used to specify the recipient's email address, this command can be used multiple times for multiple recipients. The syntax is
RCPT TO:<recipient@example.com>
. For instance:RCPT TO:<jane@example.com>
.DATA: This command indicates the beginning of the message content. It's ended by a line containing only a single period (.). After the DATA command, you would input the message content. For example:
DATA
From: john@example.com
To: jane@example.com
Subject: Hello
This is the body of the email.
.
QUIT: This simple command ends the SMTP session. Its syntax is just
QUIT
.RSET (Reset): The RSET command aborts the current mail transaction but keeps the connection open. It's useful for starting over without initiating a new connection. The syntax is simply
RSET
.AUTH (Authentication): This command is used to authenticate the client to the server and supports various authentication mechanisms. The syntax is
AUTH mechanism
, for example:AUTH LOGIN
.
A typical SMTP conversation might look like this:
C: EHLO client.example.com
S: 250-smtp.example.com Hello client.example.com
S: 250-SIZE 14680064
S: 250-AUTH LOGIN PLAIN
S: 250 HELP
C: MAIL FROM:<sender@example.com>
S: 250 OK
C: RCPT TO:<recipient@example.com>
S: 250 OK
C: DATA
S: 354 Start mail input; end with <CRLF>.<CRLF>
C: From: sender@example.com
C: To: recipient@example.com
C: Subject: Test Email
C:
C: This is a test email.
C: .
S: 250 OK: queued as 12345
C: QUIT
S: 221 Bye
Authentication in SMTP
Authentication is a crucial aspect of SMTP, especially for outbound email servers. It helps prevent unauthorized use of the server and reduces spam. There are several authentication methods used in SMTP:
PLAIN: This is a simple authentication method where the username and password are sent in clear text. It should only be used over encrypted connections.
LOGIN: Similar to PLAIN, but the username and password are sent in separate commands.
CRAM-MD5: This method uses a challenge-response mechanism to avoid sending the password in clear text.
OAUTH2: This method allows the use of OAuth 2.0 tokens for authentication.
Here's an example of how PLAIN authentication looks in an SMTP conversation:
C: EHLO example.com
S: 250-STARTTLS
S: 250 AUTH PLAIN LOGIN
C: AUTH PLAIN AGVtYWlsQGV4YW1wbGUuY29tAHBhc3N3b3Jk
S: 235 2.7.0 Authentication successful
In this example, "AGVtYWlsQGV4YW1wbGUuY29tAHBhc3N3b3Jk" is the base64-encoded version of "\0email@example.com\0password".
When implementing authentication in your SMTP server, you'll need to:
Advertise supported authentication methods in response to the EHLO command.
Implement handlers for the AUTH command that can process the chosen authentication method.
Verify the provided credentials against your user database.
Maintain the authenticated state for the duration of the SMTP session.
Now, let's move on to implementing these concepts in our Go SMTP server.
Achieving deliverability : DKIM, SPF, DMARC
Imagine sending a letter through the postal service without a return address or an official stamp. It might reach its destination, but there's a good chance it'll end up in the "suspicious mail" pile. In the digital world of email, we face a similar challenge.
How do we ensure our emails aren't just sent, but actually delivered and trusted?
Enter the holy trinity of email authentication: DKIM, SPF, and DMARC.
DKIM: Your Email's Digital Signature
DKIM (DomainKeys Identified Mail) is like a wax seal on a medieval letter. It proves the email hasn't been tampered with during transit.
How it works:
Your email server adds a digital signature to every outgoing email.
The receiving server checks this signature against a public key published in your DNS records.
If the signature is valid, the email passes the DKIM check.
Think of it as your email's passport, stamped and verified at each checkpoint.
Example DKIM DNS Record:
<selector>._domainkey.<domain>.<tld>. IN TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC3QEKyU1fSma0axspqYK5iAj+54lsAg4qRRCnpKK68hawSd8zpsDz77ntGCR0X2mHVvkHbX6dX<truncated>oIDAQAB"
Here, 'selector' is a unique identifier for this DKIM key, and the long string is your public key.
SPF: The Guest List for Your Domain's Party
SPF (Sender Policy Framework) is like the bouncer at an exclusive club. It specifies which email servers are allowed to send emails on behalf of your domain.
How it works:
You publish a list of authorized IP addresses in your DNS records.
When an email arrives claiming to be from your domain, the receiving server checks if it came from an IP on your list.
If it matches, the email passes the SPF check.
It's like saying, "If the email didn't come from one of these guys, it's not with us!"
Example SPF DNS Record:
<domain>.<tld>. IN TXT "v=spf1 ip4:192.0.2.0/24 include:_spf.google.com ~all"
This record says:
Emails can come from IP addresses in the range 192.0.2.0 to 192.0.2.255
Emails can also come from servers specified in Google's SPF record
The
~all
means to soft-fail emails from other sources (treat as suspicious but don't reject)
DMARC: The Rule Maker and Enforcer
DMARC (Domain-based Message Authentication, Reporting & Conformance) is the wise judge that decides what happens to emails that fail DKIM or SPF checks.
How it works:
You set a policy in your DNS records specifying how to handle emails that fail authentication.
Options range from "let it through anyway" to "reject it outright."
DMARC also provides reports on email authentication results, helping you monitor and improve your email security.
Think of DMARC as your email bouncer's rulebook and incident report.
Example DMARC DNS Record:
_dmarc.<domain>.<tld>. IN TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@<domain>.<tld>"
This record says:
If an email fails DKIM and SPF checks, quarantine it (typically send to spam folder)
Send aggregate reports about email authentication results to dmarc-reports@example.com
Why This Trinity Matters
Together, DKIM, SPF, and DMARC form a powerful shield against email spoofing and phishing. They tell receiving servers, "This email is really from us, sent by someone we trust, and here's what to do if something seems fishy."
Implementing this trinity not only improves your email deliverability but also protects your domain's reputation. It's like having a state-of-the-art security system for your email infrastructure.
As we build our SMTP server, keeping these authentication methods in mind will be crucial for ensuring our emails don't just get sent, but actually reach their destination and are trusted when they arrive. Remember, when implementing these records on a production domain, start with permissive policies and gradually tighten them as you confirm everything is working correctly.
Building the SMTP Server with Go
1. Project Initialization
First, let's create a new directory for our project and initialize a Go module:
mkdir go-smtp-server
cd go-smtp-server
go mod init github.com/yourusername/go-smtp-server
2. Installing Dependencies
We'll need a few dependencies for our SMTP server. Run the following commands:
go get github.com/emersion/go-smtp
go get github.com/emersion/go-sasl
go get github.com/emersion/go-msgauth
3. Basic SMTP Server Setup
Create a new file named
main.go
and add the following code:
package main
import (
"log"
"time"
"io"
"github.com/emersion/go-smtp"
)
func main() {
s := smtp.NewServer(&Backend{})
s.Addr = ":2525"
s.Domain = "localhost"
s.WriteTimeout = 10 * time.Second
s.ReadTimeout = 10 * time.Second
s.MaxMessageBytes = 1024 * 1024
s.MaxRecipients = 50
s.AllowInsecureAuth = true
log.Println("Starting server at", s.Addr)
if err := s.ListenAndServe(); err != nil {
log.Fatal(err)
}
}
// Backend implements SMTP server methods.
type Backend struct{}
func (bkd *Backend) NewSession(_ *smtp.Conn) (smtp.Session, error) {
return &Session{}, nil
}
// A Session is returned after EHLO.
type Session struct{}
// We'll implement the Session methods next
This creates a SMTP server, listening on the 2525 port, a convenient choice for development purposes, since this port doesn’t require administrative privileges, unlike the standard ports 25 (standard SMTP), 465 (TLS), 587 (STARTTLS).
Implementing EHLO/HELO
The EHLO/HELO command is handled automatically by the go-smtp
library. We don't need to implement it ourselves.
Implementing MAIL FROM
Add this method to the Session
struct:
func (s *Session) Mail(from string, opts *smtp.MailOptions) error {
fmt.Println("Mail from:", from) s.From = from
return nil
}
This method is called when the server receives a MAIL FROM command. It logs the sender's address and stores it in the session.
Implementing RCPT TO
Add this method to the Session
struct:
func (s *Session) Rcpt(to string) error {
fmt.Println("Rcpt to:", to)
s.To = append(s.To, to)
return nil
}
This method is called for each RCPT TO command. It logs the recipient's address and adds it to the list of recipients for this session.
Implementing DATA
Add this method to the Session
struct:
import (
"fmt"
"io"
)
func (s *Session) Data(r io.Reader) error {
if b, err := io.ReadAll(r); err != nil {
return err
} else {
fmt.Println("Received message:", string(b))
// Here you would typically process the email
return nil
}
}
This method is called when the server receives the DATA command. It reads the entire email message and logs it. In a real server, you would process the email here.
Implementing AUTH
Add this method to the Session
struct:
func (s *Session) AuthPlain(username, password string) error {
if username != "testuser" || password != "testpass" {
return fmt.Errorf("Invalid username or password")
}
return nil
}
This implements a basic authentication mechanism. Note that this is for demonstration purposes only and should not be used in production.
Implementing RSET
Add this method to the Session
struct:
func (s *Session) Reset() {
s.From = "" s.To = []string{}
}
This method is called when the server receives a RSET command. It resets the session state.
Implementing QUIT
Add this method to the Session
struct:
func (s *Session) Logout() error {
return nil
}
This method is called when the server receives a QUIT command. In this simple implementation, we don't need to do anything special.
Sending Emails: MX Lookup, Port Selection, and DKIM Signing
Once we've received and processed an email, the next step is to send it to its destination. This involves two key steps: finding the recipient's mail server using MX (Mail Exchanger) records, and attempting to send the email using standard SMTP ports.
First, let's add a function to look up MX records:
import "net"
func lookupMX(domain string) ([]*net.MX, error) {
mxRecords, err := net.LookupMX(domain)
if err != nil {
return nil, fmt.Errorf("Error looking up MX records: %v", err)
}
return mxRecords, nil
}
Next, let's create a function that attempts to send an email using different ports:
import (
"crypto/tls"
"net/smtp"
"strings"
)
func sendMail(from string, to string, data []byte) error {
domain := strings.Split(to, "@")[1]
mxRecords, err := lookupMX(domain)
if err != nil {
return err
}
for _, mx := range mxRecords {
host := mx.Host
for _, port := range []int{25, 587, 465} {
address := fmt.Sprintf("%s:%d", host, port)
var c *smtp.Client
var err error
switch port {
case 465:
// SMTPS
tlsConfig := &tls.Config{ServerName: host}
conn, err := tls.Dial("tcp", address, tlsConfig)
if err != nil {
continue
}
c, err = smtp.NewClient(conn, host)
case 25, 587:
// SMTP or SMTP with STARTTLS
c, err = smtp.Dial(address)
if err != nil {
continue
}
if port == 587 {
if err = c.StartTLS(&tls.Config{ServerName: host}); err != nil {
c.Close()
continue
}
}
}
if err != nil {
continue
}
// SMTP conversation
if err = c.Mail(from); err != nil {
c.Close()
continue
}
if err = c.Rcpt(to); err != nil {
c.Close()
continue
}
w, err := c.Data()
if err != nil {
c.Close()
continue
}
_, err = w.Write(data)
if err != nil {
c.Close()
continue
}
err = w.Close()
if err != nil {
c.Close()
continue
}
c.Quit()
return nil
}
}
return fmt.Errorf("Failed to send email to %s", to)
}
This function does the following:
Looks up the MX records for the recipient's domain.
For each MX record, it tries to connect using ports 25, 587, and 465 in that order.
It uses the appropriate connection method for each port:
Port 25: Plain SMTP
Port 587: SMTP with STARTTLS
Port 465: SMTPS (SMTP over TLS)
If a connection is successful, it attempts to send the email using the SMTP protocol.
If the email is sent successfully, it returns. Otherwise, it tries the next port or MX record.
Now, let's modify our `Data` method in the `Session` struct to use this new `sendMail` function:
func (s *Session) Data(r io.Reader) error {
if data, err := io.ReadAll(r); err != nil {
return err
} else {
fmt.Println("Received message:", string(data))
for _, recipient := range s.To {
if err := sendMail(s.From, recipient, data); err != nil {
fmt.Printf("Failed to send email to %s: %v", recipient, err)
} else {
fmt.Printf("Email sent successfully to %s", recipient)
}
}
return nil
}
}
This implementation will attempt to send the received email to each recipient using the appropriate mail server and port.
Now, let's add DKIM signing to our email sending process. First, we need to import the necessary packages and set up our DKIM options:
import (
// ... other imports ...
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"github.com/emersion/go-msgauth/dkim"
)
// Load your DKIM private key
var dkimPrivateKey *rsa.PrivateKey
func init() {
// Load your DKIM private key from a file
privateKeyPEM, err := ioutil.ReadFile("path/to/your/private_key.pem")
if err != nil {
log.Fatalf("Failed to read private key: %v", err)
}
block, _ := pem.Decode(privateKeyPEM)
if block == nil {
log.Fatalf("Failed to parse PEM block containing the private key")
}
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
log.Fatalf("Failed to parse private key: %v", err)
}
dkimPrivateKey = privateKey
}
// DKIM options
var dkimOptions = &dkim.SignOptions{
Domain: "example.com",
Selector: "default",
Signer: dkimPrivateKey,
}
Next, let's modify our sendMail
function to include DKIM signing:
func sendMail(from string, to string, data []byte) error {
// ... [previous MX lookup code] ...
for _, mx := range mxRecords {
host := mx.Host
for _, port := range []int{25, 587, 465} {
// ... [previous connection code] ...
// DKIM sign the message
var b bytes.Buffer
if err := dkim.Sign(&b, bytes.NewReader(data), dkimOptions); err != nil {
return fmt.Errorf("Failed to sign email with DKIM: %v", err)
}
signedData := b.Bytes()
// SMTP conversation
if err = c.Mail(from); err != nil {
c.Close()
continue
}
if err = c.Rcpt(to); err != nil {
c.Close()
continue
}
w, err := c.Data()
if err != nil {
c.Close()
continue
}
_, err = w.Write(signedData) // Use the DKIM signed message
if err != nil {
c.Close()
continue
}
err = w.Close()
if err != nil {
c.Close()
continue
}
c.Quit()
return nil
}
}
return fmt.Errorf("Failed to send email to %s", to)
}
In this updated sendMail
function:
We sign the email data with DKIM before sending it.
We use the signed data (
signedData
) when writing to the SMTP connection.
This implementation will add a DKIM signature to your outgoing emails, which will help improve deliverability and authenticity of your emails.
Remember to replace "path/to/your/private_key.pem"
with the actual path to your DKIM private key, and update the Domain
and Selector
in dkimOptions
to match your DKIM DNS record.
12. Considerations and Next Steps
While this implementation provides a basic working SMTP server capable of receiving and sending emails, there are several important considerations for a production-ready server:
Rate Limiting: Implement rate limiting to prevent abuse and protect against email bombing.
Spam Prevention: Implement measures to prevent your server from being used to send spam.
Error Handling: Improve error handling and logging for better debugging and monitoring.
Queue Management: Implement a queue system for retry logic when emails fail to send.
Conclusion
We hope you learned a lot by reading this post. To know more about sending emails, feel free to take a look at the GitHub repository of Ferdinand, and explore the code.