Документация / Go

Отправка email на Go через SMTP

SMTP-клиент в Go есть прямо в стандартной библиотеке, и для текстового уведомления его достаточно. Ниже — отправка через net/smtp на fmailer, затем go-mail для HTML, вложений и отправки с context и, наконец, воркер, который выносит отправку из HTTP-обработчиков.

ℹ
Логин и пароль можно получить на странице токенов домена.

Параметры подключения

ПараметрЗначение
SMTP-хостsmtp.fmailer.ru
Порт587
ШифрованиеSTARTTLS (соединение переводится в TLS до авторизации)
ЛогинЛогин токена вашего домена
ПарольПароль этого токена — показывается один раз, при создании
ℹ
Каждый режим доступен и на высоком порту: 8465 (SSL/TLS), 8587 (STARTTLS) и 8025 (без шифрования). Многие хостинги и облака блокируют исходящие 25, 465 и 587 — если соединение обрывается по таймауту, используйте соответствующий высокий порт.

Первое письмо через net/smtp

smtp.SendMail подключается, поднимает STARTTLS, авторизуется и отправляет письмо. Адрес в конверте и в заголовке From должен быть на подтверждённом домене, иначе DKIM и SPF не сойдутся с отправителем:

main.gogo
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 проходит без преобразований.

⚠
Соединение открывается без дедлайна, и при заблокированном порте горутина висит, пока не сдастся операционная система. В серверном коде соединение нужно ограничивать по времени — как в примере ниже.

Таймаут и context

Соберите клиент сами, чтобы управлять подключением и дедлайном. Порядок важен: StartTLS до Auth, иначе PlainAuth откажется передавать пароль открытым текстом:

go
// У 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()
}

HTML и вложения через go-mail

На многокомпонентном MIME стандартная библиотека перестаёт быть удобной. github.com/wneessen/go-mail активно поддерживается, сам собирает письмо, кодирует заголовки и принимает context:

bash
go get github.com/wneessen/go-mail
mailer.gogo
import (
  "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, включите шифрование с первого байта:

go
// Порт 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 при остановке, чтобы очередь успела уйти при штатном перезапуске:

worker.gogo
// Буферизованная очередь и несколько горутин; обработчик только кладёт письмо.
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()
}

Очередь живёт в памяти, и при падении процесса её содержимое теряется. Письма, от которых зависит пользователь, — восстановление пароля, чеки — сначала сохраняйте в базу или очередь задач, а воркер пусть забирает их оттуда.

Альтернатива: REST API

Если исходящие SMTP-порты закрыты или не хочется возиться с MIME, отправьте то же письмо по HTTPS с теми же логином и паролем токена:

go
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 connectionPlainAuth вызван до 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 не на подтверждённом домене.

Частые вопросы

Поддерживает ли net/smtp STARTTLS?

Да. smtp.SendMail сам выполняет STARTTLS, если сервер его объявляет, и делает это до авторизации, поэтому порт 587 работает без дополнительного кода. А вот шифрование с первого байта на порту 465 net/smtp само не умеет: соединение нужно открыть через tls.Dial и передать в smtp.NewClient либо взять библиотеку вроде go-mail.

Почему smtp.PlainAuth возвращает «unencrypted connection»?

PlainAuth отказывается передавать пароль по нешифрованному соединению, если сервер не localhost. Значит, STARTTLS так и не случился: обычно c.Auth вызван раньше c.StartTLS в самостоятельно собранном клиенте либо указан не тот порт.

net/smtp устарел?

Не устарел, но заморожен: новых возможностей команда Go в него не принимает. Для текстовых уведомлений его хватает. Для HTML с текстовой версией, вложений, кодирования кириллицы в заголовках и отправки с context удобнее поддерживаемая библиотека github.com/wneessen/go-mail — MIME не придётся собирать вручную.

Как отправить письмо с кириллицей в теме?

В net/smtp заголовки пишутся как есть, и кириллицу в Subject и в имени отправителя нужно кодировать самим — например, mime.QEncoding.Encode("utf-8", subject). go-mail делает это автоматически, поэтому в примере с ним тема написана по-русски без всяких преобразований.

Как задать таймаут отправки в Go?

У smtp.SendMail таймаута нет: при заблокированном порте он висит, пока не сдастся операционная система. Либо откройте соединение через net.Dialer и выставьте deadline до smtp.NewClient, либо используйте в go-mail опцию WithTimeout вместе с DialAndSendWithContext и контекстом с дедлайном.

Почему в Docker появляется «x509: certificate signed by unknown authority»?

В образах FROM scratch и на голой distroless-основе нет корневых сертификатов, поэтому TLS не может проверить сервер. Скопируйте /etc/ssl/certs/ca-certificates.crt в образ или установите пакет ca-certificates. InsecureSkipVerify для обхода ошибки не включайте.

Что дальше