SMTP-клиент в Go есть прямо в стандартной библиотеке, и для текстового уведомления его достаточно. Ниже — отправка через net/smtp на fmailer, затем go-mail для HTML, вложений и отправки с context и, наконец, воркер, который выносит отправку из HTTP-обработчиков.
| Параметр | Значение |
|---|---|
| SMTP-хост | smtp.fmailer.ru |
| Порт | 587 |
| Шифрование | STARTTLS (соединение переводится в TLS до авторизации) |
| Логин | Логин токена вашего домена |
| Пароль | Пароль этого токена — показывается один раз, при создании |
smtp.SendMail подключается, поднимает STARTTLS, авторизуется и отправляет письмо. Адрес в конверте и в заголовке From должен быть на подтверждённом домене, иначе DKIM и SPF не сойдутся с отправителем:
package main
import (
"log"
"net/smtp"
"os"
"strings"
)
func main() {
host := "smtp.fmailer.ru"
auth := smtp.PlainAuth("", os.Getenv("SMTP_USER"), os.Getenv("SMTP_PASS"), host)
// Заголовки отделены от тела пустой строкой, строки заканчиваются CRLF.
msg := strings.Join([]string{
"From: noreply@mail.example.ru",
"To: client@example.ru",
"Subject: Order confirmed",
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
"Content-Transfer-Encoding: 8bit",
"",
"Заказ принят. Доставка — завтра до 18:00.",
}, "\r\n")
// SendMail сам поднимает STARTTLS перед авторизацией.
err := smtp.SendMail(host+":587", auth, "noreply@mail.example.ru",
[]string{"client@example.ru"}, []byte(msg))
if err != nil {
log.Fatal(err)
}
} Тема здесь латиницей не случайно: net/smtp пишет заголовки как есть, и кириллицу в них нужно кодировать через mime.QEncoding. Тело в UTF-8 проходит без преобразований.
Соберите клиент сами, чтобы управлять подключением и дедлайном. Порядок важен: StartTLS до Auth, иначе PlainAuth откажется передавать пароль открытым текстом:
// У smtp.SendMail нет таймаута — соединение приходится открывать самим.
func send(ctx context.Context, from string, to []string, msg []byte) error {
host := "smtp.fmailer.ru"
d := net.Dialer{Timeout: 10 * time.Second}
conn, err := d.DialContext(ctx, "tcp", host+":587")
if err != nil {
return err
}
conn.SetDeadline(time.Now().Add(30 * time.Second))
c, err := smtp.NewClient(conn, host)
if err != nil {
return err
}
defer c.Close()
if err := c.StartTLS(&tls.Config{ServerName: host}); err != nil {
return err
}
auth := smtp.PlainAuth("", os.Getenv("SMTP_USER"), os.Getenv("SMTP_PASS"), host)
if err := c.Auth(auth); err != nil {
return err
}
if err := c.Mail(from); err != nil {
return err
}
for _, rcpt := range to {
if err := c.Rcpt(rcpt); err != nil {
return err
}
}
w, err := c.Data()
if err != nil {
return err
}
if _, err := w.Write(msg); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return c.Quit()
} На многокомпонентном MIME стандартная библиотека перестаёт быть удобной. github.com/wneessen/go-mail активно поддерживается, сам собирает письмо, кодирует заголовки и принимает context:
go get github.com/wneessen/go-mailimport (
"context"
"os"
"time"
"github.com/wneessen/go-mail"
)
func newClient() (*mail.Client, error) {
return mail.NewClient("smtp.fmailer.ru",
mail.WithPort(587),
mail.WithTLSPolicy(mail.TLSMandatory), // только STARTTLS, без открытого текста
mail.WithSMTPAuth(mail.SMTPAuthPlain),
mail.WithUsername(os.Getenv("SMTP_USER")),
mail.WithPassword(os.Getenv("SMTP_PASS")),
mail.WithTimeout(10*time.Second),
)
}
func sendInvoice(ctx context.Context, client *mail.Client, to, html, text string) error {
m := mail.NewMsg()
if err := m.FromFormat("Магазин", "noreply@mail.example.ru"); err != nil {
return err
}
if err := m.To(to); err != nil {
return err
}
m.Subject("Счёт на оплату за март")
m.SetBodyString(mail.TypeTextPlain, text)
m.AddAlternativeString(mail.TypeTextHTML, html)
m.AttachFile("/srv/invoices/2026-03.pdf")
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
return client.DialAndSendWithContext(ctx, m)
} Всегда отправляйте текстовую часть вместе с HTML: письмо без неё почтовые фильтры считают признаком спама. AttachFile молча пропускает файл, который не может прочитать, поэтому, если вложение важно, проверьте путь заранее.
Если в сети открыт только порт 465, включите шифрование с первого байта:
// Порт 465: шифрование с первого байта вместо STARTTLS.
client, err := mail.NewClient("smtp.fmailer.ru",
mail.WithPort(465),
mail.WithSSL(),
mail.WithSMTPAuth(mail.SMTPAuthPlain),
mail.WithUsername(os.Getenv("SMTP_USER")),
mail.WithPassword(os.Getenv("SMTP_PASS")),
) Обработчик должен положить письмо в очередь и сразу ответить. Для большинства приложений хватает небольшого пула воркеров с повторами. Вызывайте Shutdown при остановке, чтобы очередь успела уйти при штатном перезапуске:
// Буферизованная очередь и несколько горутин; обработчик только кладёт письмо.
type Mailer struct {
client *mail.Client
jobs chan *mail.Msg
wg sync.WaitGroup
}
func NewMailer(client *mail.Client, workers int) *Mailer {
m := &Mailer{client: client, jobs: make(chan *mail.Msg, 100)}
for i := 0; i < workers; i++ {
m.wg.Add(1)
go m.run()
}
return m
}
func (m *Mailer) run() {
defer m.wg.Done()
for msg := range m.jobs {
for attempt := 1; attempt <= 3; attempt++ {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
err := m.client.DialAndSendWithContext(ctx, msg)
cancel()
if err == nil {
break
}
log.Printf("попытка %d не удалась: %v", attempt, err)
time.Sleep(time.Duration(attempt) * 5 * time.Second)
}
}
}
func (m *Mailer) Enqueue(msg *mail.Msg) { m.jobs <- msg }
// Shutdown перестаёт принимать письма и дожидается отправки очереди.
func (m *Mailer) Shutdown() {
close(m.jobs)
m.wg.Wait()
}Очередь живёт в памяти, и при падении процесса её содержимое теряется. Письма, от которых зависит пользователь, — восстановление пароля, чеки — сначала сохраняйте в базу или очередь задач, а воркер пусть забирает их оттуда.
Если исходящие SMTP-порты закрыты или не хочется возиться с MIME, отправьте то же письмо по HTTPS с теми же логином и паролем токена:
type sendRequest struct {
Recipient string `json:"recipient"`
Subject string `json:"subject"`
Body string `json:"body"`
Text string `json:"text,omitempty"`
Sender string `json:"sender"`
Auth map[string]string `json:"auth"`
}
func sendViaAPI(ctx context.Context, to, subject, html, text string) error {
payload, _ := json.Marshal(sendRequest{
Recipient: to,
Subject: subject,
Body: html,
Text: text,
Sender: "Магазин <noreply@mail.example.ru>",
Auth: map[string]string{
"username": os.Getenv("SMTP_USER"),
"password": os.Getenv("SMTP_PASS"),
},
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api.fmailer.ru/external/send_email_simple/", bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("send failed: %s", resp.Status)
}
return nil
}| Ошибка | Причина и решение |
|---|---|
unencrypted connection | PlainAuth вызван до TLS. Сначала StartTLS, либо порт 587 с SendMail. |
wrong host name | Хост в PlainAuth не совпадает с тем, к которому подключались. Укажите smtp.fmailer.ru в обоих местах. |
535 — ошибка авторизации | Неверный логин или пароль токена. |
dial tcp …: i/o timeout или зависание | Порт заблокирован. Используйте 8587 для STARTTLS, 8465 для шифрования с первого байта или REST API. |
net/smtp висит на порту 465 | Он начинает разговор открытым текстом. Используйте 587 либо tls.Dial или WithSSL в go-mail. |
x509: certificate signed by unknown authority | В образе контейнера нет корневых сертификатов. Установите их. |
| Тема «кракозябрами» | Кириллица в заголовке не закодирована. Используйте mime.QEncoding или go-mail. |
550 — отправитель отклонён | Адрес в From не на подтверждённом домене. |
Да. smtp.SendMail сам выполняет STARTTLS, если сервер его объявляет, и делает это до авторизации, поэтому порт 587 работает без дополнительного кода. А вот шифрование с первого байта на порту 465 net/smtp само не умеет: соединение нужно открыть через tls.Dial и передать в smtp.NewClient либо взять библиотеку вроде go-mail.
PlainAuth отказывается передавать пароль по нешифрованному соединению, если сервер не localhost. Значит, STARTTLS так и не случился: обычно c.Auth вызван раньше c.StartTLS в самостоятельно собранном клиенте либо указан не тот порт.
Не устарел, но заморожен: новых возможностей команда Go в него не принимает. Для текстовых уведомлений его хватает. Для HTML с текстовой версией, вложений, кодирования кириллицы в заголовках и отправки с context удобнее поддерживаемая библиотека github.com/wneessen/go-mail — MIME не придётся собирать вручную.
В net/smtp заголовки пишутся как есть, и кириллицу в Subject и в имени отправителя нужно кодировать самим — например, mime.QEncoding.Encode("utf-8", subject). go-mail делает это автоматически, поэтому в примере с ним тема написана по-русски без всяких преобразований.
У smtp.SendMail таймаута нет: при заблокированном порте он висит, пока не сдастся операционная система. Либо откройте соединение через net.Dialer и выставьте deadline до smtp.NewClient, либо используйте в go-mail опцию WithTimeout вместе с DialAndSendWithContext и контекстом с дедлайном.
В образах FROM scratch и на голой distroless-основе нет корневых сертификатов, поэтому TLS не может проверить сервер. Скопируйте /etc/ssl/certs/ca-certificates.crt в образ или установите пакет ca-certificates. InsecureSkipVerify для обхода ошибки не включайте.