
Перевод статьи Build Your Own SMTP Server in Go
В Valyent мы разрабатываем открытое программное обеспечение для разработчиков. Как часть этой миссии мы разработали Ferdinant - наш сервис рассылки почты для разработчиков(пока в альфе).
Почтовая инфраструктура состоит из нескольких протоколов, самые важные из них:
- SMTP (Simple Mail Transfer Protocol): Используется для отправки и получения почты между почтовыми серверами.
- IMAP (Internet Message Access Protocol): Позволяет пользователям читать и управлять почтой на сервере.
- POP3 (Post Office Protocol version 3): Скачивает почту с сервера на девайс пользователя, как правило, удаляя ее с сервера
В этой статье мы фокусируемся на написании исходящего SMTP сервера, повторяя подход, который мы применили при разработке Ferdinant. В процессе разберемся с самыми критичными компонентами инфраструктуры для отправки почты.
"Чего не могу воссоздать, того не понимаю"
Ричард Фейнман
Разрабатывая SMTP сервер, вы погрузитесь в тему отправки электронной почты на такой уровень, до которого большинство разработчиков не добираются.
Для разработки мы будем использовать язык программирования Go вместе с несколькими замечательными библиотеками от Simon Ser. Мы разберемся в механизме работы почты, посмотрим, как отправлять электронные письма на другие серверы, и даже проясним ключевые понятия, такие как SPF, DKIM и DMARC, которые обеспечивают оперативность доставки.
По итогу, мы не напишем прям на 100% готового к продакшену SMTP сервера, но точно получи более глубокое представление о механизме электронной почты.
Основы SMTP
Пред тем как начать писать код, давайте разберемся что такое SMTP и как он работает. SMTP (Simple Mail Transfer Protocol) это стандартный протокол для отправки почты через интернет. Это относительно простой текстовый протокол взаимодействия между клиентом и сервером.
Команды SMTP
В протоколе используется набор команд. Каждая команда выполняет определенную функцию в процессе передачи почты. Они позволяют серверам узнавать друг о друге, указывать отправителей и получателей, передавать содержимое электронной почты и управлять сессиями. Эти команды можно представить как диалог между двумя почтовыми серверами, где каждая команда представляет собой конкретное утверждение или вопрос в этом диалоге
Когда вы разрабатываете SMTP сервер, вы создаете программу, которая может "бегло" общаться на языке этих команд, интерпретировать входящие команды, соответствующе на них реагировать и по необходимости выдавать в свои команды во время отправки почты
Рассмотрим самые важные команды SMTP протокола, чтобы понять как структурировано взаимодействие клиента и сервера:
- ELO/EHLO (Hello): Эта команда начинает SMTP взаимодействие. EHLO это расширенная версия команды в SMTP протоколе, которая поддерживает некоторые дополнительные функции. Синтаксис очен простой
HELO domain
илиEHLO domain
. ПримерEHLO: example.com
- MAIL FROM: Эта команда указывает адрес почты отправителя и начинает новую почтовую транзакцию. Используется синтаксис
MAIL FROM:<sender@example.com>
. Пример команды:john@example.com - RCPT TO: Используется для указания адреса электронной почты получателя. Команду можно использовать несколько раз для нескольких получателей. Синтаксис команды:
RCPT TO:<recipient@example.com>
- DATA: Эта команда указывает начало содержимого сообщения. Она заканчивается строкой, содержащей одну точку (.). После команды DATA нужно указывать содержимое сообщения. Например:
DATA
From: john@example.com
To: jane@example.com
Subject: Hello
This is the body of the email.
.
- QUIT: Эта простая команда заканчивает SMTP сессию. Синтаксис очень простой, просто
QUIT
- RSET (Reset): Команда RSET сбрасывает текущую транзакцию, но оставляет соединение открытым. Удобно когда нужно начать все сначала, не устанавливая новое соединение. Синтаксис тоже супер простой
RSET
- AUTH (Authentication): Эта команда используется для аутентификации клиента на сервере. Как правило, поддерживается набор различных механизмов аутентификации. Синтаксис команды:
AUTH mechanism
. Например:AUTH LOGIN
Типичное SMTP взаимодействие выглядит как-то так:
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
Аутентификация в SMTP
Аутентификация это важный аспект SMTP, особенно для серверов исходящей электронной почты. Это помогает предотвратить несанкционированное использование сервера и уменьшает количество нежелательной почты. В SMTP используется несколько методов аутентификации:
- PLAIN: Это простой способ аутентификации, когда логин и пароль передаются в открытом виде. Такой метод нужно использовать только при защищенном соединении
- LOGIN: Похоже на PLAIN, но логин и пароль передаются в разных командах
- CRAM-MD5: Этот метод использует механизм запрос-ответ, чтобы избежать отправку пароля в открытом виде
- OAUTH2: Позволяет использовать OAuth 2.0 токен для аутентификации
Пример как выглядит PLAIN авторизация в SMTP взаимодействии:
C: EHLO example.com
S: 250-STARTTLS
S: 250 AUTH PLAIN LOGIN
C: AUTH PLAIN AGVtYWlsQGV4YW1wbGUuY29tAHBhc3N3b3Jk
S: 235 2.7.0 Authentication successful
В этом примере, AGVtYWlsQGV4YW1wbGUuY29tAHBhc3N3b3Jk
это заэндкодженная в base64 строка вида \0email@example.com\0password
.
Для реализации в SMTP сервере необходимо:
- Анонсировать поддерживаемые методы аутентификации в ответе на команду HELO.
- Реализовать обработчик команды AUTH, который сможет обрабатывать указанные методы авторизации.
- Сверить предоставленные данные с данными в базе.
- Поддерживать состояние аутентификации на протяжении всей SMTP сессии.
Этого уже достаточно, чтобы начать реализацию нашего сервера, но важно обсудить еще одну важную концепцию
Надежная доставка: DKIM, SPF, DMARC
Представьте, что вы отправляете письмо через почту России без обратного адреса или марки. Возможно, оно дойдет до адресата, но велика вероятность, что оно окажется в списке "подозрительных писем". В цифровом мире электронной почты мы сталкиваемся с аналогичной проблемой.
Как мы можем быть уверены, что письмо не просто отправлено, но и проходит проверку доверенности, и точно будет доставлено?
Встречайте "святую троицу" аутентификации в мире электронной почты: DKIM, SPF, и DMARC.
DKIM: Цифровая подпись для ваших писем
DKIM (DomainKeys Identified Mail) - это как сургучная печать на средневековом письме, которая подтверждает что письмо не было подделано при пересылке
Как это работает:
- Ваш почтовы сервер добавляет подпись к каждому исходящему письму
- Сервер, который получает письмо, проверяет подпись используя публичный ключ, опубликованный в DNS записях
- Если подпись валидна, то письмо проходит проверку DKIM
Представьте что у вашего электронного адреса есть паспорт, который заверяется печатью на каждом контрольно-пропускном пункте.
Пример DNS записи с указанием DKIM:
<selector>._domainkey.<domain>.<tld>. IN TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQU
AA4GNADCBiQKBgQC3QEKyU1fSma0axspqYK5iAj+54lsAg4qRRCnpKK68hawSd8zpsDz77ntGCR0X2mHVvkHbX6
dX<truncated>oIDAQAB"
Тут selector
это уникальный идентификатор для этого DKIM ключа, а длинная строка - ваш открытый ключ.
SPF: Список гостей на вечеринке вашего домена
SPF (Sender Policy Framework) работает как вышибала в вашем VIP клубе. Он определяет каким почтовым серверам разрешено отправлять электронные письма от имени вашего домена.
Как это работает:
- Вы публикуете список авторизованных IP-адресов в своих DNS записях.
- Когда приходит электронное письмо, в котором утверждается, что оно отправлено с вашего домена, сервер-получатель проверяет, пришло ли оно с одного из разрешенных IP-адресов.
- Если это так, то SPF проверка пройдена
Это все равно что сказать: "Если электронное письмо пришло не от одного из этих парней, значит, оно не от нас!"
Пример DNS с указанием SPF:
<domain>.<tld>. IN TXT "v=spf1 ip4:192.0.2.0/24 include:_spf.google.com ~all"
Эта запись обозначает:
- Письма могут приходить от IP-адресов в диапазоне от 192.0.2.0 до 192.0.2.255
- Письма также могут приходить от серверов с указанием SPF записи Google
~all
означаетмягкую
проверку почты от других источников для предотвращения сбоев. Письма будут рассматриваться как подозрительные, но не отклонятся.
DMARC: Создатель и блюститель правил
DMARC (Domain-based Message Authentication, Reporting & Conformance) это мудрый судья. Он решает, что происходит с электронными письмами, которые не прошли проверку DKIM или SPF.
Как это работает:
- Вы устанавливаете политики в своих DNS записях, определяющие, как обрабатывать электронные письма, которые не проходят проверку подлинности.
- Варианты обработки варьируются от "не обращать внимание" до "наотрез не пропускать"
- DMARC также предоставляет отчеты о результатах проверки подлинности электронной почты, помогая вам отслеживать и улучшать безопасность вашей электронной почты.
Можно представить что DMARC это свод правил для вашего почтового вышибалы и доступ к отчету о происшествиях.
Пример DNS записи DMARC:
_dmarc.<domain>.<tld>. IN TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@<domain>.<tld>"
Что означает эта запись:
- Ели письмо не проходит DKIM или SPF, то оно отправляется в карантин(как правило, перемещается в папку спам)
- Отправляется агрегированный отчет про результаты проверки на почту dmarc-reports@example.com
Почему эта троица имеет значение
Вместе DKIM, SPF и DMARC создают мощную защиту от подмены электронной почты и фишинга. Они сообщают принимающим серверам: "Это электронное письмо действительно от нас, отправлено человеком, которому мы доверяем. Если что-то покажется подозрительным, то сделай вот это".
Внедрение этих систем не только улучшает качество доставки электронной почты, но и защищает репутацию вашего домена. Это как использовать современную систему безопасности для инфраструктуры электронной почты.
При создании нашего SMTP-сервера учет описанных систем проверок и аутентификаций будет иметь решающее значение для того, чтобы наши электронные письма не просто отправлялись, а еще и доходили до адресата и не попадали в папку спам. Помните, что при добавлении DNS записей для продакшен домена начинайте с разрешительных политик и постепенно ужесточайте их по мере того, как будете убеждаться, что все работает правильно.
Создание SMTP сервера на Go
Инициализация проекта
В первую очередь, создаем новую директорию для проекта и инициализируем Go mod:
mkdir go-smtp-server
cd go-smtp-server
go mod init github.com/yourusername/go-smtp-server
Инициализация зависимостей
Нужно сразу подтянуть несколько модулей, которые понадобятся в нашем SMTP сервере:
go get github.com/emersion/go-smtp
go get github.com/emersion/go-sasl
go get github.com/emersion/go-msgauth
Базовый SMTP сервер
Создаем новый файл с названием main.go и добавляем туда код
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 реализация SMTP сервера
type Backend struct{}
func (bkd *Backend) NewSession(_ *smtp.Conn) (smtp.Session, error) {
return &Session{}, nil
}
// Session объект создается после команды EHLO.
type Session struct{}
// Теперь реализуем методы Session
Тут мы создаем SMTP сервер, который слушает на порту 2525. этот порт удобно использовать в разработке, для него не нужно иметь административных прав, как для стандартного 25
Примечание переводчика: тут может быть не очень понятно, но конструктор
mtp.NewServer()
принимает интерфейсsmtp.Backend
, который состоит из одного методаNewSession(_ *smtp.Conn) (smtp.Session, error)
и дальше сервер оперирует с интерфейсомsmtp.Session
. Именно интерфейсsmtp.Session
мы будем реализовывать дальше по коду. По сути, у нас получится полноценный сервер, но прием писем у нас уже реализован на стороне либыgo-smtp
, а отправку мы реализуем сами. Собранный в кучу код можно посмотреть в примере к либеgo-smtp
.
Реализация EHLO/HELO
На самом деле, эта команда уже реализована в библиотеке go-smtp
. Профит.
Реализация MAIL FROM
Добавляем метод для структуры Session
:
func (s *Session) Mail(from string, opts *smtp.MailOptions) error {
fmt.Println("Mail from:", from)
s.From = from
return nil
}
Этот метод вызывается когда сервер получает команду MAIL FROM
. В этом методе логируется адрес отправки и сохраняет его в сессии.
Реализация RCPT TO
Добавляем новый метод в структуру Session
:
func (s *Session) Rcpt(to string) error {
fmt.Println("Rcpt to:", to)
s.To = append(s.To, to)
return nil
}
Метод вызывается для каждой команды RCPT TO
. В нем логируется адрес получателя и этот адрес добавляется в список получателей в сессии
Реализация DATA
Добавляем новый метод в структуру Session
:
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))
// Тут происходит обработка письма
return nil
}
}
Этот метод вызывается когда сервер получает команду DATA
. В методе вычитывается содержимое почтового сообщений и логируется. В реальном сервере именно тут должна быть обработка письма.
Реализация AUTH
Добавляем еще один метод в структуру Session
:
func (s *Session) AuthPlain(username, password string) error {
if username != "testuser" || password != "testpass" {
return fmt.Errorf("Invalid username or password")
}
return nil
}
В этом методе реализуется плеин(базовая) авторизация. Эта реализация тут только для примера. Не рекомендуется использовать такую авторизацию в продакшене.
Реализация RSET
Добавляем метод(уже чуть-чуть осталось) в структуру Session
:
func (s *Session) Logout() error {
return nil
}
Этот метод вызывается, когда сервер получает команду QUIT
. В этой простой реализации нам не нужно делать ничего особенного.
Отправка писем: выбор порта, поиск MX записи и использование DKIM подписи
Как только мы получим и обработаем электронное письмо, следующим шагом будет отправка его по назначению. Для этого нужно выполнить два ключевых шага: поиск почтового сервера получателя с помощью записей MX (Mail Exchanger) и попытка отправить электронное письмо через стандартные SMTP-порты.
Сначала реализуем функцию поиска MX записей. К счастью, в стандартной библиотеке есть готовый метод net.LookupMX(domain)
:
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
}
Теперь можно добавить функцию в которой будем пытаться отправить письмо через возможные доступные порты
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 или SMTP с 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 взаимодействие
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)
}
Эта функция работает так:
- Находим MX записи для адреса получателя, используя реализованную ранее функцию
lookupMX(domain)
- Для каждой MX записи пытаемся подключиться по портам в таком порядке: 25, 587 и 465
- В зависимости от порта используем разные способы для соединения:
- 25 порт: плеин SMTP
- 587 порт: SMTP c STARTTLS
- 465 порт: SMTPS (SMTP через TLS)
- Если соединение установлено, то пытаемся отправить письмо по SMTP протоколу.
- Если письмо удачно отправлено, то на этом останавливаемся. В случае ошибки, пытаемся повторить отправку по другому порту или MX запись.
Теперь нужно модифицировать метод Data
в структуре Session
, добавляем туда реализованную выше функцию sendMail()
:
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
}
}
В этой функции мы пытаемся отправить полученное письмо каждому получателю используя соответствующий почтовый сервер и порт.
Теперь давайте встроим подписание DKIM в наш процесс отправки электронной почты. Сначала нам нужно импортировать необходимые пакеты и настроить параметры DKIM:
import (
// ...
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"github.com/emersion/go-msgauth/dkim"
)
// Загружаем приватный DKIM ключ
var dkimPrivateKey *rsa.PrivateKey
func init() {
// Загружаем приватный DKIM ключ из файла
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 опции
var dkimOptions = &dkim.SignOptions{
Domain: "example.com",
Selector: "default",
Signer: dkimPrivateKey,
}
Нужно модифицировать нашу функцию sendMail
, добавить туда механизм подписания DKIM
func sendMail(from string, to string, data []byte) error {
// ...
for _, mx := range mxRecords {
host := mx.Host
for _, port := range []int{25, 587, 465} {
// ...
// Подписание сообщения DKIM подписью
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 взаимодействие
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) // Используем сообщение, подписанное DKIM
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)
}
В этой обновленной функции
- Перед отправкой мы подписываем письмо с помощью DKIM
- Используем подписанное сообщение перед отправкой в SMTP соединение
Эта реализация добавит DKIM подпись к исходящим электронным письмам, что поможет улучшить их доставку без попадания в спам.
Не забудьте заменить path/to/your/private_key.pem
на реальный путь к вашему закрытому DKIM ключу и обновить Domain
и Selector
в dkimOptions
, чтобы они соответствовали вашей DNS записи для DKIM.
Заключение
Хоть мы и реализовали рабочий SMTP-сервер, способный принимать и отправлять электронные письма, существует несколько важных моментов про которые нельзя забывать:
- Ограничение по лимитам. Необходимо ограничить возможность взаимодействия для защиты от бомбинга.
- Ограничения спама. Нужно принять меры для предотвращения использования вашего сервера для рассылки спама.
- Обработка ошибок. Нужно улучшить обработку ошибок и логирование для простой отладки сервера.
- Очереди. Для организации ретраев и обработки большого количества писем неплохо было бы внедрить механизм очередей.