Пишем свой SMTP сервер на Go

14 minute read

Перевод статьи 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-сервер, способный принимать и отправлять электронные письма, существует несколько важных моментов про которые нельзя забывать:

  • Ограничение по лимитам. Необходимо ограничить возможность взаимодействия для защиты от бомбинга.
  • Ограничения спама. Нужно принять меры для предотвращения использования вашего сервера для рассылки спама.
  • Обработка ошибок. Нужно улучшить обработку ошибок и логирование для простой отладки сервера.
  • Очереди. Для организации ретраев и обработки большого количества писем неплохо было бы внедрить механизм очередей.

Ссылки

  • Ferdinant - сервис рассылки почты для разработчиков.
  • Simon Ser - набор библиотек для работы с почтовыми протоколами
  • go-smtp - библиотека для реализации SMTP сервера