Пишем свой SMTP сервер на Go
Перевод статьи 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:
1mkdir go-smtp-server
2cd go-smtp-server
3go mod init github.com/yourusername/go-smtp-server
Инициализация зависимостей
Нужно сразу подтянуть несколько модулей, которые понадобятся в нашем SMTP сервере:
1go get github.com/emersion/go-smtp
2go get github.com/emersion/go-sasl
3go get github.com/emersion/go-msgauth
Базовый SMTP сервер
Создаем новый файл с названием main.go и добавляем туда код
1package main
2
3import (
4 "log"
5 "time"
6 "io"
7
8 "github.com/emersion/go-smtp"
9)
10
11func main() {
12 s := smtp.NewServer(&Backend{})
13
14 s.Addr = ":2525"
15 s.Domain = "localhost"
16 s.WriteTimeout = 10 * time.Second
17 s.ReadTimeout = 10 * time.Second
18 s.MaxMessageBytes = 1024 * 1024
19 s.MaxRecipients = 50
20 s.AllowInsecureAuth = true
21
22 log.Println("Starting server at", s.Addr)
23 if err := s.ListenAndServe(); err != nil {
24 log.Fatal(err)
25 }
26}
27
28// Backend реализация SMTP сервера
29type Backend struct{}
30
31func (bkd *Backend) NewSession(_ *smtp.Conn) (smtp.Session, error) {
32 return &Session{}, nil
33}
34
35// Session объект создается после команды EHLO.
36type Session struct{}
37
38// Теперь реализуем методы 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
:
1func (s *Session) Mail(from string, opts *smtp.MailOptions) error {
2 fmt.Println("Mail from:", from)
3 s.From = from
4 return nil
5}
Этот метод вызывается когда сервер получает команду MAIL FROM
. В этом методе логируется адрес отправки и сохраняет его в сессии.
Реализация RCPT TO
Добавляем новый метод в структуру Session
:
1func (s *Session) Rcpt(to string) error {
2 fmt.Println("Rcpt to:", to)
3 s.To = append(s.To, to)
4 return nil
5}
Метод вызывается для каждой команды RCPT TO
. В нем логируется адрес получателя и этот адрес добавляется в список получателей в сессии
Реализация DATA
Добавляем новый метод в структуру Session
:
1import (
2 "fmt"
3 "io"
4)
5
6func (s *Session) Data(r io.Reader) error {
7 if b, err := io.ReadAll(r); err != nil {
8 return err
9 } else {
10 fmt.Println("Received message:", string(b))
11
12 // Тут происходит обработка письма
13 return nil
14 }
15}
Этот метод вызывается когда сервер получает команду DATA
. В методе вычитывается содержимое почтового сообщений и логируется. В реальном сервере именно тут должна быть обработка письма.
Реализация AUTH
Добавляем еще один метод в структуру Session
:
1func (s *Session) AuthPlain(username, password string) error {
2 if username != "testuser" || password != "testpass" {
3 return fmt.Errorf("Invalid username or password")
4 }
5
6 return nil
7}
В этом методе реализуется плеин(базовая) авторизация. Эта реализация тут только для примера. Не рекомендуется использовать такую авторизацию в продакшене.
Реализация RSET
Добавляем метод(уже чуть-чуть осталось) в структуру Session
:
1func (s *Session) Logout() error {
2 return nil
3}
Этот метод вызывается, когда сервер получает команду QUIT
. В этой простой реализации нам не нужно делать ничего особенного.
Отправка писем: выбор порта, поиск MX записи и использование DKIM подписи
Как только мы получим и обработаем электронное письмо, следующим шагом будет отправка его по назначению. Для этого нужно выполнить два ключевых шага: поиск почтового сервера получателя с помощью записей MX (Mail Exchanger) и попытка отправить электронное письмо через стандартные SMTP-порты.
Сначала реализуем функцию поиска MX записей. К счастью, в стандартной библиотеке есть готовый метод net.LookupMX(domain)
:
1import "net"
2
3func lookupMX(domain string) ([]*net.MX, error) {
4 mxRecords, err := net.LookupMX(domain)
5 if err != nil {
6 return nil, fmt.Errorf("Error looking up MX records: %v", err)
7 }
8
9 return mxRecords, nil
10
11}
Теперь можно добавить функцию в которой будем пытаться отправить письмо через возможные доступные порты
1import (
2 "crypto/tls"
3 "net/smtp"
4 "strings"
5)
6
7func sendMail(from string, to string, data []byte) error {
8 domain := strings.Split(to, "@")[1]
9
10 mxRecords, err := lookupMX(domain)
11 if err != nil {
12 return err
13 }
14
15 for _, mx := range mxRecords {
16 host := mx.Host
17
18 for _, port := range []int{25, 587, 465} {
19 address := fmt.Sprintf("%s:%d", host, port)
20
21 var c *smtp.Client
22
23 var err error
24
25 switch port {
26 case 465:
27 // SMTPS
28 tlsConfig := &tls.Config{ServerName: host}
29 conn, err := tls.Dial("tcp", address, tlsConfig)
30 if err != nil {
31 continue
32 }
33
34 c, err = smtp.NewClient(conn, host)
35
36 case 25, 587:
37 // SMTP или SMTP с STARTTLS
38 c, err = smtp.Dial(address)
39 if err != nil {
40 continue
41 }
42
43 if port == 587 {
44 if err = c.StartTLS(&tls.Config{ServerName: host}); err != nil {
45 c.Close()
46 continue
47 }
48 }
49 }
50
51 if err != nil {
52 continue
53 }
54
55 // SMTP взаимодействие
56 if err = c.Mail(from); err != nil {
57 c.Close()
58
59 continue
60 }
61
62 if err = c.Rcpt(to); err != nil {
63 c.Close()
64 continue
65 }
66
67 w, err := c.Data()
68
69 if err != nil {
70 c.Close()
71 continue
72
73 }
74
75 _, err = w.Write(data)
76 if err != nil {
77 c.Close()
78 continue
79
80 }
81
82 err = w.Close()
83
84 if err != nil {
85 c.Close()
86 continue
87
88 }
89
90 c.Quit()
91
92 return nil
93 }
94 }
95
96 return fmt.Errorf("Failed to send email to %s", to)
97}
Эта функция работает так:
- Находим MX записи для адреса получателя, используя реализованную ранее функцию
lookupMX(domain)
- Для каждой MX записи пытаемся подключиться по портам в таком порядке: 25, 587 и 465
- В зависимости от порта используем разные способы для соединения:
- 25 порт: плеин SMTP
- 587 порт: SMTP c STARTTLS
- 465 порт: SMTPS (SMTP через TLS)
- Если соединение установлено, то пытаемся отправить письмо по SMTP протоколу.
- Если письмо удачно отправлено, то на этом останавливаемся. В случае ошибки, пытаемся повторить отправку по другому порту или MX запись.
Теперь нужно модифицировать метод Data
в структуре Session
, добавляем туда реализованную выше функцию sendMail()
:
1func (s *Session) Data(r io.Reader) error {
2 if data, err := io.ReadAll(r); err != nil {
3 return err
4 } else {
5 fmt.Println("Received message:", string(data))
6 for _, recipient := range s.To {
7 if err := sendMail(s.From, recipient, data); err != nil {
8 fmt.Printf("Failed to send email to %s: %v", recipient, err)
9 } else {
10 fmt.Printf("Email sent successfully to %s", recipient)
11 }
12
13 }
14
15 return nil
16 }
17}
В этой функции мы пытаемся отправить полученное письмо каждому получателю используя соответствующий почтовый сервер и порт.
Теперь давайте встроим подписание DKIM в наш процесс отправки электронной почты. Сначала нам нужно импортировать необходимые пакеты и настроить параметры DKIM:
1import (
2 // ...
3 "crypto/rsa"
4 "crypto/x509"
5 "encoding/pem"
6 "github.com/emersion/go-msgauth/dkim"
7)
8
9// Загружаем приватный DKIM ключ
10var dkimPrivateKey *rsa.PrivateKey
11
12func init() {
13 // Загружаем приватный DKIM ключ из файла
14 privateKeyPEM, err := ioutil.ReadFile("path/to/your/private_key.pem")
15 if err != nil {
16 log.Fatalf("Failed to read private key: %v", err)
17 }
18
19 block, _ := pem.Decode(privateKeyPEM)
20 if block == nil {
21 log.Fatalf("Failed to parse PEM block containing the private key")
22 }
23
24 privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
25 if err != nil {
26 log.Fatalf("Failed to parse private key: %v", err)
27 }
28
29 dkimPrivateKey = privateKey
30}
31
32// DKIM опции
33var dkimOptions = &dkim.SignOptions{
34 Domain: "example.com",
35 Selector: "default",
36 Signer: dkimPrivateKey,
37}
Нужно модифицировать нашу функцию sendMail
, добавить туда механизм подписания DKIM
1func sendMail(from string, to string, data []byte) error {
2 // ...
3
4 for _, mx := range mxRecords {
5 host := mx.Host
6 for _, port := range []int{25, 587, 465} {
7 // ...
8
9 // Подписание сообщения DKIM подписью
10 var b bytes.Buffer
11 if err := dkim.Sign(&b, bytes.NewReader(data), dkimOptions); err != nil {
12 return fmt.Errorf("Failed to sign email with DKIM: %v", err)
13 }
14 signedData := b.Bytes()
15
16 // SMTP взаимодействие
17 if err = c.Mail(from); err != nil {
18 c.Close()
19 continue
20 }
21 if err = c.Rcpt(to); err != nil {
22 c.Close()
23 continue
24 }
25 w, err := c.Data()
26 if err != nil {
27 c.Close()
28 continue
29 }
30 _, err = w.Write(signedData) // Используем сообщение, подписанное DKIM
31 if err != nil {
32 c.Close()
33 continue
34 }
35 err = w.Close()
36 if err != nil {
37 c.Close()
38 continue
39 }
40 c.Quit()
41 return nil
42 }
43 }
44
45 return fmt.Errorf("Failed to send email to %s", to)
46}
В этой обновленной функции
- Перед отправкой мы подписываем письмо с помощью DKIM
- Используем подписанное сообщение перед отправкой в SMTP соединение
Эта реализация добавит DKIM подпись к исходящим электронным письмам, что поможет улучшить их доставку без попадания в спам.
Не забудьте заменить path/to/your/private_key.pem
на реальный путь к вашему закрытому DKIM ключу и обновить Domain
и Selector
в dkimOptions
, чтобы они соответствовали вашей DNS записи для DKIM.
Заключение
Хоть мы и реализовали рабочий SMTP-сервер, способный принимать и отправлять электронные письма, существует несколько важных моментов про которые нельзя забывать:
- Ограничение по лимитам. Необходимо ограничить возможность взаимодействия для защиты от бомбинга.
- Ограничения спама. Нужно принять меры для предотвращения использования вашего сервера для рассылки спама.
- Обработка ошибок. Нужно улучшить обработку ошибок и логирование для простой отладки сервера.
- Очереди. Для организации ретраев и обработки большого количества писем неплохо было бы внедрить механизм очередей.