Перейти к содержимому

SMTP-протокол на ESP32: отправка сообщений электронной почты

Доброго здравия, уважаемые читатели!

В настоящее время многие привычные и сравнительно простые способы доставки уведомлений с различных умных устройств все чаще и чаще оказываются не доступны по тем или иным причинам. Поэтому приходится искать другие способы для своевременного оповещения пользователя о произошедших событиях. В данной статье рассмотрим способ, как можно отправить самое обычное сообщение электронной почты (email) непосредственно с ESP32, используя ESP-IDF API.

Предупреждение: будет много кода и скучного текста, без картинок. Без пол-литры не разобраться.


SMTP-протокол

SMTP (Simple Mail Transfer Protocol) — это стандартный протокол для отправки электронной почты, который изначально создавался для передачи простого текста в кодировке ASCII. SMTP возник в 1980 году на основе более ранних разработок ARPANET, первый официальный стандарт — RFC 821 (1982 год). Современная версия протокола, которую в основном используют сегодня, описана в новом стандарте RFC 5321 (2008 год) [https://www.rfc-editor.org/rfc/rfc5321, https://datatracker.ietf.org/doc/rfc5321/].

Базовый SMTP (RFC 821) не поддерживает шифрование и передачу двоичных данных (файлов). Эти возможности появились позже как расширения:

  • MIME (RFC 1521/2045) — для передачи вложений и возможности использования разных кодировок символов.
  • ESMTP (Extended SMTP) — расширение протокола, согласно которой клиент и сервер договариваются о поддерживаемых возможностях (например, о максимальном размере письма или шифровании) через команду EHLO.
  • STARTTLS (RFC 3207) — расширение для шифрования соединения после установки открытого соединения.

Я не ставил целью переписать здесь эти стандарты, более подробное и точное описание вы найдете в официальной документации, ссылки на которые приведены в конце статьи. Здесь приводятся только сведения, необходимые для понимания логики работы кода.

 

Как передаются сообщения электронной почты

SMTP — это line-based текстовый протокол, работающий поверх TCP, и он не передает двоичные данные напрямую. Изначально SMTP мог передавать только 7-битные символы ASCII. Современные расширения (8BIT MIME) позволяют передавать другие кодировки символов, но обычно клиенты используют дополнительное кодирование, например Base64. Этот же способ используется и для передачи файлов.

Клиент и сервер обмениваются текстовыми командами и ответами, при этом каждая строка всегда заканчивается CRLF (\r\n). При этом учтите, что парсер ответов сервера должен читать строку целиком до \r\n, а не «сколько пришло в пакете», потому что TCP может склеивать и дробить ответы произвольно.

Для отправки письма по SMTP клиент (ваш ESP32) выполняет строго упорядоченную последовательность команд. Каждая команда — это текстовая строка, заканчивающаяся CRLF (\r\n). Например  базовая минимальная последовательность команд следующая: HELO → MAIL FROM → RCPT TO → DATA → . → QUIT. Но современный почтовый сервер может потребовать более расширенную последовательность команд, типичный пример представлен в таблице ниже:

Этап Команда Назначение Ожидаемый ответ
Сессия EHLO Открыть ESMTP, узнать расширения 250 (многострочный)
Шифрование STARTTLS Апгрейд до TLS (только если объявлен) 220 → TLS handshake
Сессия (повтор) EHLO Обновить список расширений внутри TLS 250
Авторизация AUTH LOGIN / AUTH PLAIN Вход (только если объявлен сервером) 235 успех / 535 отказ
Транзакция MAIL FROM Начать транзакцию, отправитель 250
Транзакция RCPT TO Получатель (можно несколько раз) 250 / 251
Транзакция DATA Запросить передачу тела 354
Данные [тело] + . Заголовки, тело, завершение 250
Завершение QUIT Закрыть сессию 221

На каждую команду сервер отвечает трёхзначным кодом в текстовом формате (плюс иногда дополнительный текст), где первая цифра кода определяет класс:

  • 2xx — успех (можно переходить к следующему шагу),
  • 3xx — промежуточный ответ (сервер ждёт продолжения, например 354 на DATA),
  • 4xx — временная ошибка (можно повторить позже),
  • 5xx — постоянная ошибка (повторять бессмысленно).

Парсер ответов сервера должен читать строку целиком до \r\n, а не «сколько пришло в пакете», потому что TCP может склеивать и дробить ответы произвольно. Логика клиента может строиться на разборе первой цифры кода ответа, а не всего текста, но иногда важен и дополнительный текст, и его тоже можно учитывать.

 

Установка сессии: HELO / EHLO

После подключения сервер первым присылает приветствие клиенту в виде строки 220\r\n (Service ready). Клиент должен ответить на это приветствие одной из двух команд:

  • HELO — устаревшая команда. Открывает сессию в режиме обычного SMTP. Сервер может ответить на это кодом 250, но не сообщит о своих расширениях.
  • EHLO — современная команда (Extended HELO), открывает сессию в режиме ESMTP. В ответ на неё сервер присылает многострочный ответ 250 с перечнем поддерживаемых расширений (STARTTLS, AUTH, SIZE и т.д.). Именно из ответа на EHLO клиент узнаёт, поддерживает ли сервер аутентификацию, шифрование STARTTLS, SIZE, 8BITMIME, SMTPUTF8. Логика выбора стратегии передачи (шифровать или нет, какой лимит размера) “по хорошему” должна строится на парсинге этого списка.

Для современного клиента правильная стратегия всегда начинать с EHLO. Если сервер очень-очень старый и вдруг ответит ошибкой (например, 500 или 502), можно откатиться на HELO, но на практике современные серверы (Gmail, Mail.ru) всегда требуют именно EHLO.

Но недостаточно просто отправить EHLO\r\n. В качестве последнего аргумента этой команды вы должны отправить должен содержать полностью определённое доменное имя (FQDN) клиента, если оно у него есть. То есть локальное имя, которое идентифицирует ваш хост для внешнего мира. Стандарт также допускает использование адресного литерала (например EHLO [192.168.1.10]), если у клиента нет осмысленного доменного имени. В некоторых примерах в качестве данного имени указывается LOCALHOST (или ESP32), но это настолько распространённый признак спам-ботов, что некоторые провайдеры почты сразу-же возвращают возвращают ошибку вида 550 Please use a fully-qualified domain name for HELO/EHLO. Поэтому если вы хотите доставить почту по назначению, не стоит так делать, хотя и во многих случаях это работает.

Ответ на EHLO — многострочный. Каждая строка начинается с 250, но дальше идёт либо дефис (250-), либо пробел (250 ). Пробел означает, что это последняя строка ответа. Это критично для парсера: нельзя читать только первую строку, иначе вы не увидите поддерживаемые расширения STARTTLS или AUTH, которые часто идут в конце.

Пример реального ответа Gmail (из документации и примеров сессий):

250-mx.google.com at your service, [1.2.3.4]
250-SIZE 35882577
250-8BITMIME
250-STARTTLS
250-ENHANCEDSTATUSCODES
250 PIPELINING

Здесь видно, что STARTTLS поддерживается, а вот AUTH в этом ответе отсутствует. Это важный нюанс: Gmail (и многие другие серверы) не показывает AUTH до того, как будет установлено TLS-соединение.

Для выбора выбора режима вас интересуют в первую очередь следующие расширения:

  • STARTTLS — сервер поддерживает апгрейд до TLS на текущем порту (обычно 587). Если этого слова нет, а порт 587 — шифрование недоступно, и слать пароль в открытом виде нельзя.
  • AUTH — список механизмов аутентификации. Появляется либо сразу (на порту 465, где TLS уже есть), либо только после STARTTLS. В ответе будет, например, 250-AUTH LOGIN PLAIN или 250-AUTH PLAIN LOGIN XOAUTH2.
  • SIZE — максимальный размер письма. Полезно, чтобы заранее отклонить отправку гигантского вложения, не начиная DATA.
  • 8BITMIME — можно ли слать 8-битный текст без кодирования (для современных серверов обычно да, но для вложений всё равно нужен будет Base64) .

 

Шифрование

Изначально SMTP поддерживал только открытые подключения поверх стандартного TCP. Но современные SMTP серверы (например yandex, gmail) могут легко отказать в таком подключении. Совеременный SMTP стандарт поддерживает два типа шифрования:

  • SMTPS (implicit TLS) — TLS-соединение устанавливается сразу с момента подключения к серверу, при этом обычно используется порт 465 (RFC 8314).
  • STARTTLS (RFC 3207) — вначале подключение осуществляется открытым текстом, затем клиент отправляет команду STARTTLS, и после получения подтверждения от сервера 220 — выполняет TLS-handshake поверх того же сокета. Стандартный порт для такого подключения 587.

На ESP32 обе схемы реализуемы через стандартные библиотеки WiFiClientSecure или mbedTLS, но STARTTLS требует «апгрейда» уже открытого сокета, а это нетривиально в Arduino-экосистеме, часто гораздо проще выполнить сразу implicit TLS на порт 465. Тут также следует понимать, что для проверки сертификата сервера потребуется корневой доверенный сертификат удостоверяющего центра, но в данном случае мы это обсуждать не будем, так как обо всем этом можно прочитать здесь.

 

Команда STARTTLS инициирует переход с открытого текста на TLS на том же порту (обычно 587). Она не имеет параметров и отправляется только после того, как сервер в ответе на EHLO явно указал STARTTLS. Ожидаемые ответы:

  • 220 Ready to start TLS — можно начинать TLS-handshake.
  • 501 Syntax error — команда отправлена с параметрами (ошибка клиента).
  • 454 TLS not available — временная недоступность, можно попробовать позже.

Критичный нюанс: после успешного TLS-handshake клиент обязан отправить EHLO повторно – только во втором ответе сервер раскроет AUTH и другие расширения, доступные внутри защищённого канала. Если этого не сделать, AUTH может быть отклонён или не показан вовсе. Также сервер может ответить 530 Must issue a STARTTLS command first на попытку MAIL/RCPT/AUTH до активации шифрования — это сигнал, что политика сервера требует TLS.

 

Аутентификация

Сам SMTP аутентификации не содержит — она была добавлена расширением RFC 4954. Механизмы (PLAIN, LOGIN, CRAM-MD5, XOAUTH2) объявляются в ответе на EHLO. Практически все публичные серверы сейчас требуют как минимум:

  • PLAIN или LOGIN с логином и паролем (для Gmail и Яндекс сейчас используется так называемый «пароль приложения», обычный пароль аккаунта уже не сработает),
  • XOAUTH2 для OAuth2-токенов.

Данные в команде AUTH передаются в кодировке Base64, а не в открытом виде, не не стоит путать Base64 с шифрованием – это просто способ кодирования двоичной информации в ASCII-символах. Команда AUTH LOGIN + две Base64-строки (логин и пароль) — самый простой для реализации механизм, AUTH PLAIN чуть-чуть короче – одна строка \0login\0password в Base64.

AUTH LOGIN — диалоговый режим, но может быть проще для реализации логики:

C: AUTH LOGIN
S: 334 VXNlcm5hbWU6      (Base64("Username:"))
C: <base64(логин)>
S: 334 UGFzc3dvcmQ6      (Base64("Password:"))
C: <base64(пароль)>
S: 235 Authentication successful

AUTH PLAIN — вариант покороче, клиент отправляет одну строку: AUTH PLAIN <base64(\0логин\0пароль)>. Сервер отвечает 235 при успехе, либо возвращает код ошибки.

Ключевые коды ответов на команду авторизации:

  • 235 — успех.
  • 535 — неверные учётные данные (повторять с тем же паролем бессмысленно).
  • 534 — выбранный механизм слишком слаб для политики сервера.
  • 454 — временная ошибка сервера, можно попробовать повторить позже.
  • 538 — механизм требует шифрования, которого нет (устаревший код, но может встретиться) .

Важно: AUTH LOGIN и AUTH PLAIN передают учётные данные в Base64, то есть без шифрования. Поэтому на порту 587 их отправляют только после команды STARTTLS и установки TLS-соединения, а на порту 465 — сразу поверх implicit TLS.

Примечание: если вы настраиваете отправку почты через smtp-сервер Яндекса или Гугла, то их почтовый сервер не примет ваш общий пароль от аккаунта, а потребует создать специальный пароль приложения.

 

Транзакция: MAIL FROM → RCPT TO → DATA

Транзакция передачи сообщения начинается с MAIL FROM, затем передается список получателей RCPT TO, и только после этого тело сообщения после команды DATA.

MAIL FROM:<адрес@отправителя>

Эта команда объявляет отправителя сообщения (reverse-path). Это адрес, на который вернётся ошибка, если письмо не дойдёт. Данная команда всегда должна быть первой в транзакции и сбрасывает предыдущее состояние незавершенной транзакции и начинает новую.

Синтаксис строгий: MAIL FROM:<user@example.com>. Пробелы вокруг двоеточия после FROM не допускаются. Ожидаемый ответ: 250 OK.

RCPT TO:<адрес@получателя>

Эта команда объявляет получателя сообщения (forward-path). Эту команду можно повторять несколько раз для нескольких получателей. Каждый вызов добавляет одного получателя в буфер.

Синтаксис: RCPT TO:<recipient@example.com>. Ожидаемый ответ: 250 или 251 (если пользователь не локальный, но сообщение будет переслано на другой сервер. Ошибка 550 означает, что ящика получателя нет или доступ к нему запрещён.

DATA

Команда «Начинаю передачу тела письма», ожидаемый ответ на неё 354 (Intermediate reply). Только после получения 354 клиент имеет право отправлять само письмо. Если же сервер вернул ошибку 5xx (например, 503 «command out of sequence» или 554 «no valid recipients»), тело отправлять нельзя — транзакция не состоялась, идите лесом, уважаемый.

После получения ответа 354 клиент отправляет всё письмо: заголовки (From, To, Subject, MIME-Version и т.д.), пустую строку, тело (текст или MIME-структуру с Base64 для вложений). Конец письма обозначается строкой, состоящей из одной точки: <CRLF>.<CRLF>. Здесь кроется маленькая, но важная деталь: если какая-то строка в теле письма начинается с точки, её нужно экранировать, добавив ещё одну точку в начало (..) – это предотвращает случайное завершение письма раньше времени.

Ожидаемый ответ после точки: 250 OK означает, что сервер принял письмо и поставил в очередь отправки / пересылки.

 

Завершение сессии: QUIT

Команда QUIT сообщает серверу, что клиент закончил работу. Сервер отвечает 221 (Closing transmission channel) и закрывает соединение. Если вы просто закроете сокет без QUIT, сервер может расценить это как обрыв и сохранить письмо как «подозрительное» или не зафиксировать транзакцию корректно.

 

Немного про контроль спама, SPF/DKIM/DMARC

Современные почтовые провайдеры (типа Яндекс и Gmail) ужесточают требования к пересылаемому письму: как правило сейчас требуется указывать корректное имя клиента, использовать SPF/DKIM/DMARC на сервере отправителя, устанавливаются лимиты на количество отправляемых писем, осуществляется блокировка «подозрительных» клиентов — поэтому ваше письмо может не дойти до получателя, даже если SMTP-диалог отправки прошёл успешно.

Здесь важно обозначить границу ответственности: SPF, DKIM и DMARC — это вообще не часть SMTP-протокола. Обычная SMTP-библиотека для ESP32 не реализует их на уровне диалога с сервером. Это механизмы аутентификации домена, которые настраиваются на стороне DNS и почтового сервера отправителя, а проверяются они принимающим сервером уже после того, как письмо принято по SMTP. Иными словами: библиотека на ESP32 просто доставляет письмо до SMTP-сервера по стандартному протоколу. Всё, что происходит дальше (подпись DKIM, проверка SPF, применение политики DMARC) — задача серверной инфраструктуры. Eсли вы отправляете письмо через публичный SMTP-сервер (Gmail, Mail.ru, Яндекс, корпоративный), эти механизмы, скорее всего, уже настроены провайдером. Если же вы поднимаете собственный SMTP-сервер, то вам придется бы настраивать DNS-записи самостоятельно.

SPF позволяет владельцу домена опубликовать в DNS список IP-адресов серверов, которым разрешено отправлять почту от имени этого домена. Принимающий сервер смотрит на IP-адрес отправителя (того, кто подключился по SMTP) и проверяет, есть ли он в SPF-записи домена, указанного в MAIL FROM (envelope-from).

DKIM добавляет к письму цифровую подпись, которая позволяет принимающему серверу проверить, что письмо действительно отправлено доменом и не было изменено в пути. Подпись создаётся с помощью приватного ключа на стороне отправителя, а публичный ключ публикуется в DNS.

DMARC — это надстройка, которая говорит принимающему серверу, что делать, если письмо провалило и SPF, и DKIM (с учётом alignment). Политика DMARC публикуется в DNS в записи _dmarc.вашдомен. Ключевое понятие — Alignment: DMARC проверяет, что домен в заголовке From (тот, что видит получатель) совпадает с доменом, прошедшим SPF-проверку (в MAIL FROM), или с доменом DKIM-подписи. Это предотвращает ситуацию, когда злоумышленник подделывает From, но проходит SPF с другого домена.


Практическая реализация для ESP-IDF

В составе ESP-IDF нет готового API для SMTP-клиента, как нет его и в реестре компонентов ESP-IDF. Но есть пример реализации, который можно найти в папке \esp-idf\examples\protocols\smtp_client\. На ее основе этого примера я создал вариант, который “умеет” общаться с разными серверами и передавать сообщения кириллицей в HTML-формате. По большому счету, на GitHub-е можно найти готовые библиотеки и для ESP-IDF, и для Arduino core, но я не очень люблю пользоваться сторонними решениями.

Код написан для ESP-IDF 5.5, но теоретически должен работать и на Arduino core. Теоретически, потому что я не проверял – вы можете сделать это самостоятельно.

 

Параметры подключения к SMTP-серверу

Для того, чтобы отправить сообщение через SMTP-сервер, нам понадобятся:

  • Адрес SMTP сервера, например smtp.yandex.ru.
  • Порт SMTP-сервера, к которому мы будем подключаться. Порт зависит от того, какой тип шифрования мы будем использовать (см ниже). Примечание: порт обычно задается как строка, а не как число, хотя на самом деле это число от 0 до 65535.
  • Тип шифрования: 
    • OPEN – Без шифрования. Порт по умолчанию 25.
    • TLS (SSL) – клиент сразу же подключается к серверу с использованием SSL или TLS-шифрования. Соответственно, для проверки сертификата сервера вам понадобится корневой доверенный сертификат удостоверяющего центра или cert bundle от Mozilla. Подробнее про TLS и сертификаты тут. Порт по умолчанию для большинства почтовых провайдеров – 465.
    • STARTTLS – клиент вначале подключается к серверу без шифрования, и через команду EHLO узнает, поддерживает ли сервер STARTTLS. Если да, отправляет команду STARTTLS и выполняет апгрейд соединения на том же самом порту. В принципе, совсем не обязательно парсить ответ сервера – если вы заранее знаете, что сервер поддерживает STARTTLS, можно действовать напролом. Что и встречается в некоторых примерах. Порт по умолчанию для большинства почтовых провайдеров – 587.
  • Логин пользователя. Для большинства современных серверов требуется авторизация. В качестве логина иногда используется имя учетной записи (без @mail.com), а иногда – адрес отправителя (вместе с @mail.com). 
  • Пароль пользователя. Пароль учетной записи, от имени которой будем отправлять письмо. Но для современных серверов Google Mail, Yandex Mail и т.д. придется создать специальный пароль приложения, так как сервер отклонит авторизацию с “общим” паролем учетной записи.
  • Адрес электронной почты отправителя, от имени которого отправляется письмо. Он не обязательно должен совпадать с учетной записью пользователя, но при отличии сервер будет относится к данному письму с большим подозрением.
  • Адрес электронной почты получателя. В данной реализации на данный момент разрешен только один получатель.

Эти параметры я объединил в одной структуре:

/**
 * @brief Тип подключения к серверу SMTP
 */
typedef enum {
    SMTP_ENCRYPTION_OPEN = 0,       /*!< Без шифрования (не безопасно) */
    SMTP_ENCRYPTION_TLS,            /*!< TLS-шифрование */
    SMTP_ENCRYPTION_STARTTLS,       /*!< Вначале открытое подключение, потом переход на TLS-шифрование (безусловно) */
    SMTP_ENCRYPTION_AUTO            /*!< Вначале открытое подключение, потом переход на TLS-шифрование, если сервер разрешает это */      
} re_smtp_encryption_type_t;

/**
 * @brief Параметры для отправки сообщения через SMTP
 */
typedef struct {
    char* server_name;          /*!< Имя сервера */
    char* server_port;          /*!< Порт (число в строковом виде) */
    re_smtp_encryption_type_t encryption;   /*!< Тип подключения к серверу SMTP */
    bool skip_cert_check;       /*!< Не проверять сертификат сервера */
    char* login;                /*!< Логин для входа на сервер (оставьте NULL, чтобы использовать sender_email) */
    char* password;             /*!< Пароль пользователя или пароль приложения */
    char* sender_email;         /*!< Адрес электронной почты отправителя */
    char* recipient_email;      /*!< Адрес(а) электронной почты получателя(ей) */
} re_smtp_server_config_t;

Дополнительный параметр skip_cert_check позволяет пропустить проверку сертификата сервера при установке TLS-соединений. Конечно, это снижает безопасность такого подключения (сертификат, например, может быть отозван из-за проблем на сервере), но зато позволяет немного сэкономить стек задачи (в контексте которой выполняется отправка) и не требует корневого сертификата. В данном случае советовать я вам не буду – выбирайте режим только под свою ответственность.

Дабы упростить настройку параметров, добавил Kconfig меню:

menu "Notifications settings for SMTP (email)"
    depends on RE_NOTIFY_SMTP_ENABLE

    config RE_NOTIFY_SMTP_SERVER
        string "Mail server name"
        default "smtp.googlemail.com"
        help
            RU: Имя почтового сервера или его адрес
            EN: Mail server name or address

    config RE_NOTIFY_SMTP_PORT
        string "Mail server port"
        default "25"
        help
            RU: Номер порта почтового сервера
            EN: Mail server port number

    choice RE_NOTIFY_SMTP_ENCRYPTION
        prompt "Server connection encryption type"
        help
            RU: Тип шифрования соединения с сервером.
            EN: Server connection encryption type.

        config RE_NOTIFY_SMTP_ENCRYPTION_OPEN
            bool "OPEN (25)"
        config RE_NOTIFY_SMTP_ENCRYPTION_TLS
            bool "TLS (465)"
        config RE_NOTIFY_SMTP_ENCRYPTION_STARTTLS
            bool "STARTTLS (587)"
        config RE_NOTIFY_SMTP_ENCRYPTION_AUTO
            bool "AUTO (587)"

    endchoice

    config RE_NOTIFY_SMTP_SKIP_SERT_CHECK
        bool "Do not verify server certificate"
        default n
        help
            RU: Не проверять сертификат сервера для TLS
            EN: Do not verify server certificate for TLS

    config RE_NOTIFY_SMTP_LOGIN
        string "User login"
        default "sender.email@gmail.com"
        help
            RU: Имя пользователя отправителя (логин)
            EN: User name (login)

    config RE_NOTIFY_SMTP_PASSWORD
        string "User password"
        default "password@123"
        help
            RU: Пароль отправителя
            EN: User password

    config RE_NOTIFY_SMTP_SENDER_MAIL
        string "Sender email"
        default "sender.email@gmail.com"
        help
            RU: Адрес электронной почты отправителя
            EN: Sender's email address

    config RE_NOTIFY_SMTP_RECIPIENT_MAIL
        string "Recipient email"
        default "recipient.email@gmail.com"
        help
            RU: Адрес электронной почты получателя
            EN: Recipient email address
        
    config RE_NOTIFY_SMTP_BUFFER_SIZE
        int "SMTP min buffer size"
        range 256 16384
        default 512
        help
            RU: Минимальный размер буфера для обмена данных с сервером.
                Здесь задан минимально необходимый размер для двухстроннего обмена данными с сервером.
                Если тело сообщения превысит заданный размер, буфер будет увеличен автоматически.
            EN: Minimal buffer size for data exchange with the server. 
                This specifies the minimum size required for two-way data exchange with the server. 
                If the message body exceeds the specified size, the buffer will be automatically expanded.
endmenu

Здесь есть некоторые “дополнительные” параметры, вроде размера буфера, о назначении которых будет сказано ниже.

 

Чтение параметров из sdkconfig реализовано очень просто:

// Конфигурация сервера "по умолчанию" (из sdkconfig)
re_smtp_server_config_t re_smtp_default_config()
{
    re_smtp_server_config_t smtp_def = {
        .server_name = (char*)CONFIG_RE_NOTIFY_SMTP_SERVER,
        .server_port = (char*)CONFIG_RE_NOTIFY_SMTP_PORT,
        #if CONFIG_RE_NOTIFY_SMTP_ENCRYPTION_TLS
            .encryption = SMTP_ENCRYPTION_TLS,
        #elif CONFIG_RE_NOTIFY_SMTP_ENCRYPTION_STARTTLS
            .encryption = SMTP_ENCRYPTION_STARTTLS,
        #elif CONFIG_RE_NOTIFY_SMTP_ENCRYPTION_AUTO  
            .encryption = SMTP_ENCRYPTION_AUTO,
        #else   
            .encryption = SMTP_ENCRYPTION_OPEN,
        #endif // CONFIG_RE_NOTIFY_SMTP_ENCRYPTION
        #if CONFIG_RE_NOTIFY_SMTP_SKIP_SERT_CHECK
            .skip_cert_check = true,
        #else
            .skip_cert_check = false,
        #endif // CONFIG_RE_NOTIFY_SMTP_SKIP_SERT_CHECK
        .login = (char*)CONFIG_RE_NOTIFY_SMTP_LOGIN,
        .password = (char*)CONFIG_RE_NOTIFY_SMTP_PASSWORD,
        .sender_email = (char*)CONFIG_RE_NOTIFY_SMTP_SENDER_MAIL,
        .recipient_email = (char*)CONFIG_RE_NOTIFY_SMTP_RECIPIENT_MAIL,
    };
    return smtp_def;
}

Ссылки на строки “по умолчанию” ведут непосредственно в .rodata на flash-памяти, по окончании работ ничего удалять и освобождать не нужно.


Основной алгоритм

Итак, для отправки письма нам необходимо реализовать следующий алгоритм:

  1. Подключаемся к серверу, в случае успешного соединения сервер пришлет приветствие с кодом 220, например: 220 mail-nwsmtp-smtp-production-main-49 Ok 1794075832-qNUKhgEktiE0 (разумеется, текст после кода 220 может быть совершенно другим).
  2. Отправляем команду EHLO [IP-адрес] (ну или просто EHLO ESP32 – на большинстве серверов это тоже прокатывает) и ждем ответ 250 со списком поддерживаемых возможностей.
  3. Если мы подключались “в открытую” на порту 587 – парсим ответ и проверяем, поддерживает ли сервер STARTTLS. Если не поддерживает (или мы уже подключились с шифрованием через 465 порт) – переходим к шагу 4. Если подключались “в отрытую” и поддерживает, выполняем дополнительную последовальность:
    • Отправляем команду STARTTLS, сервер должен ответить 220 Go ahead
    • Выполняем TLS-рукопожатие на том же порту и том же сокете, не закрывая соединения
    • Еще раз отправляем команду EHLO [IP-адрес] 
  4. В случае успеха отправляем команду AUTH LOGIN. Если сервер ответил кодом 334, по очереди, двумя отдельными командами, отправляем закодированные в Base64 логин и пароль пользователя. Важно! Base64 – это не шифрование, это просто способ кодирования информации, расшифровать Base84 очень просто без всяких паролей и криптографии. Иногда сервер на этом этапе может послать далеко и надолго в пешее эротическое путешествие, например 535 5.7.8 error: authentication failed: this user does not have access rights to this service – это у Яндекса значит, что пароль не подходит.
  5. При успешной авторизации отправляем адрес электронной почты отправителя: MAIL FROM:<sender@yandex.ru>, сервер должен ответить кодом 250 2.1.0 <sender@yandex.ru> ok 1794076273-CVUVHpGdSuQ0-l8fe8FOS.
  6. Затем отправляем один адрес адрес электронной почты получателя: RCPT TO:<recipient@yandex.ru>. Ответ также должен быть 250.
  7. Начинаем отправку собственно сообщения: отправляем команду DATA. Сервер отвечает: 354 Start mail input, end with <CRLF>.<CRLF>. Здесь вы сразу можете прочитать инструкцию: начинайте передачу, окончание по символам <CRLF>.<CRLF>.
  8. Отправляем тело сообщения (подробнее ниже), заканчиваем его указанной последовательностью <CRLF>.<CRLF>. Получив сообщение, сервер должен ответить 250 2.0.0 Ok: queued on mail-nwsmtp-smtp-production-main-81 1794076273-CVUVHpGdSuQ0-l8fe8FOS (разумеется, текст после кода 250 может быть совершенно другим).
  9. Завершаем сеанс командой QUIT и выдыхаем закрываем сокет.

Пример выполнения указанного алгоритма на реальном устройстве для почты Яндекса вы можете увидеть в журнале:

I (11453) re_smtp: Send message: Тестовое сообщение : <b>устройство запущено</b>
I (11513) re_smtp: Recieved from SMTP server: 220 mail-nwsmtp-smtp-production-main-81 Ok 1794076272-CVUVHpGdSuQ0
I (11513) re_smtp: Send to SMTP server: EHLO [192.168.33.117]
I (11543) re_smtp: Recieved from SMTP server: 250-mail-nwsmtp-smtp-production-main-81
250-8BITMIME
250-PIPELINING
250-SIZE 53477376
250-STARTTLS
250-AUTH LOGIN PLAIN XOAUTH2
250-DSN
250 ENHANCEDSTATUSCODES
I (11543) re_smtp: Send to SMTP server: STARTTLS
I (11573) re_smtp: Recieved from SMTP server: 220 Go ahead
I (11743) esp-x509-crt-bundle: Certificate validated
I (12383) re_smtp: Send to SMTP server: EHLO [192.168.33.117]
I (12403) re_smtp: Recieved from SMTP server: 250-mail-nwsmtp-smtp-production-main-81
250-8BITMIME
250-PIPELINING
250-SIZE 53477376
250-STARTTLS
250-AUTH LOGIN PLAIN XOAUTH2
250-DSN
250 ENHANCEDSTATUSCODES
I (12413) re_smtp: Send to SMTP server: AUTH LOGIN
I (12433) re_smtp: Recieved from SMTP server: 334 VXNlcm5hbWU6
I (12433) re_smtp: Send to SMTP server: AAAAAAAAAAAAAAAAAAA==
I (12453) re_smtp: Recieved from SMTP server: 334 UGFzc3dvcmQ6
I (12453) re_smtp: Send to SMTP server: BBBBBBBBBBBBBBBBBBB==
I (12513) re_smtp: Recieved from SMTP server: 235 2.7.0 Authentication successful. 1794076273-CVUVHpGdSuQ0
I (12513) re_smtp: Send to SMTP server: MAIL FROM:<sender@yandex.ru>
I (12553) re_smtp: Recieved from SMTP server: 250 2.1.0 <sender@yandex.ru> ok 1794076273-CVUVHpGdSuQ0-l8fe8FOS
I (12553) re_smtp: Send to SMTP server: RCPT TO:<recipient@yandex.ru>
I (12583) re_smtp: Recieved from SMTP server: 250 2.1.5 <recipient@yandex.ru> recipient ok 1794076273-CVUVHpGdSuQ0-l8fe8FOS
I (12583) re_smtp: Send to SMTP server: DATA
I (12743) re_smtp: Recieved from SMTP server: 354 Start mail input, end with <CRLF>.<CRLF>
I (12743) re_smtp: Send to SMTP server: From: sender@yandex.ru
To: recipient@yandex.ru
Subject: =?utf-8?B?8J+SrCDQodC+0L7QsdGJ0LXQvdC40LUg0L7RgiBFU1AzMi1URVNU?=
MIME-Version: 1.0
Content-Type: text/html; charset=utf-8

<html><body><p>Тестовое сообщение : <b>устройство запущено</b></p><p><code>01.01.70 03:00</code></p></body></html>
.
I (13083) re_smtp: Recieved from SMTP server: 250 2.0.0 Ok: queued on mail-nwsmtp-smtp-production-main-81 1794076273-CVUVHpGdSuQ0-l8fe8FOS
I (13083) re_smtp: Send to SMTP server: QUIT
I (13113) re_smtp: Recieved from SMTP server: 221 2.0.0 Closing connecton
I (13113) re_smtp: Email successfully sent

Как видите – на первый взгляд все просто, даже не о чём говорить дальше. Но как всегда, дьявол кроется в деталях…


Сокеты, mbedTLS и все, все, все…

Первое, с чем придется столкнуться для решения данной задачи – сокеты и mbedTLS. Для обмена данными с сервером не получится использовать высокоуровневые компоненты вроде http_client API, придется спускаться на уровень ниже. То есть придется использовать mbedTLS API. То есть mbedtls_ssl_read, mbedtls_ssl_write, mbedtls_ssl_handshake и иже с ними…

Библиотека mbedTLS дает нам несколько базовых функций для передачи данных через TCP.

  • mbedtls_ssl_write – отправка данных через сокет с использованием SSL
  • mbedtls_net_send – отправка данных через сокет в raw-виде (без шифрования)
  • mbedtls_ssl_read – получение данных через сокет с использованием SSL
  • mbedtls_net_recv – получение данных через сокет в raw-виде (без шифрования)

Возникает соблазн сделать чтение и запись “за один проход”, например так:

mbedtls_ssl_read(&(smtp_context->ssl_context), (unsigned char *)buffer, buffer_size - 1);
ESP_LOGD(logTAG, "Recieved from SMTP server: %s", buffer);

Такой подход даже будет работать во многих случаях. Но не всегда! А всё потому, что сокеты не гарантируют передачу (или прием) всего буфера целиком и сразу: связь может прерываться, и данные будут приходить или отправляться частями. Это нужно обязательно учесть в коде! 

Например отправка данных из буфера повторяется до тех пор, пока все байты не будут отправлены:

// Отправляем буфер
size_t bytes_left = cmd_len;
const char *ptr = command;
int sent = 0;
while (bytes_left > 0) {
    if (smtp_context->use_mbedtls) {
        sent = mbedtls_ssl_write(&(smtp_context->ssl_context), (const unsigned char*)ptr, bytes_left);
    } else {
        sent = mbedtls_net_send(&(smtp_context->server_fd), (const unsigned char*)ptr, bytes_left);
    }
    if (sent == MBEDTLS_ERR_SSL_WANT_WRITE) {
        continue;
    }
    if (sent < 0) {
        ESP_LOGE(logTAG, "Socket send failed: errno %d", -sent);
        return ESP_FAIL;
    }
    bytes_left -= sent;
    ptr += sent;
}

И в третьих. как вы уже поняли, придется использовать буфер или буферы для обмена данных с сервером. В этот буфер мы должны будем поместить данные для отправки на сервер, в него придет ответ от сервера (иногда используют два отдельных буфера для передачи и приема, но я отказался от этой идеи “напрямую”). Больная тема, так как выделение памяти для буфера влечет за собой существенный расход RAM – либо в куче, либо на стеке задачи, либо в .bss. На этом стоит остановится чуть-чуть подробнее

 


Буфер для обмена данными с сервером

Чисто технически у нас по сути три варианта размещения буфера:

  • в общей куче – через функции malloc или calloc
  • на стеке задачи – путем объявления локальной переменной типа const char buffer[512];
  • в секции .bss – путем объявления статической переменной static const char buffer[512];

Что-же выбрать?

Буфер в куче (heap)

Для выделения буфера в куче используйте стандартные функции выделения динамической памяти, например char *buffer = malloc(size); или heap_caps_malloc(size, MALLOC_CAP_8BIT) в ESP-IDF.

Сильные стороны:

  • Размер буфера может быть изменен на этапе выполнения — например, из конфигурации, из ответа сервера или вычислен по фактической потребности. Это дает гибкость размера: буфер создаётся тогда, когда известен нужный размер, и может быть разным для разных экземпляров контекста (один SMTP-клиент с буфером 512 байт, другой — 8 КБ).
  • Время жизни буфера не привязано к функции, в которой он был создан. Буфер живёт ровно столько, сколько вам нужно, и не исчезает при выходе из функции — это критично для асинхронных сценариев и для случаев, когда буфер переиспользуется между вызовами разных функий.
  • Существенная экономия стека вызывающей задачи. Стек задачи в ESP-IDF обычно 2–8 КБ; большой буфер на стеке может легко его переполнить. Общая куча позволяет держать буферы в килобайты, не трогая стек.
  • Возможность использовать разные типы памяти. В ESP-IDF через heap_caps_malloc можно попросить память из внутренней RAM, из PSRAM, из DMA-совместимой области — то есть выбрать физически подходящий регион памяти под конкретную задачу.

Слабые стороны:

  • Риск фрагментации памяти и риск утечек. Каждый malloc нужно парно освободить; при ошибках на пути освобождения легко получить утечку памяти. Фрагментация кучи — также реальная проблема при условии, если буфер “живет” долго.
  • Неопределённость на этапе старта: malloc может вернуть NULL, и это нужно обрабатывать это везде, где буфер используется.
  • Чуть больше накладных расходов: выделение и освобождение памяти — не бесплатные операции, хотя для буфера, создаваемого один раз при инициализации, это несущественно.

Буфер в общей куче уместен, когда он нужен относительно ненадолго (на обмена с сервером), его размер может варьироваться, или буфер большой настолько, что стеку его нельзя доверить. 

Буфер на стеке задачи (stack)

Буфер в стеке задачи выглядит просто как char buffer[BUFFER_SIZE]; внутри функции. Память выделяется автоматически при входе в функцию и освобождается при выходе — никакого malloc/free не нужно.

Сильные стороны:

  • Простота и безопасность: не нужно думать об освобождении, об NULL, об утечках памяти. Время жизни такого буфера чётко привязано к функции в которой он был объявлен.
  • Скорость выделения буфера — это просто сдвиг указателя стека, часто одна процессорная инструкция. Нет фрагментации кучи, нет накладных расходов аллокатора.
  • Локальность. Буфер всегда рядом с другими локальными переменными, хорошо ложится в кэш (на ESP32 кэш есть, на ESP8266 — нет, но принцип тот же).

Слабые стороны:

  • Размер такого буфера должен быть зафиксирован ещё на этапе компиляции. Если реальный ответ сервера окажется больше — произойдет переполнение стека, и это будет уже не просто «ошибка», а падение программы или тихая порча соседних данных.
  • Размер буфера ограничен размером стека задачи. В ESP-IDF стек задачи по умолчанию 2–4 КБ (у app_main — больше, у системных задач — меньше), поэтому буфер в несколько килобайт на стеке — почти гарантированная проблема, особенно если функция вызывается глубоко.
  • Время жизни — только функция в которой он объявлен. Если буфер нужен между вызовами (когда команда формируется в одной функции, ответ читается в другой), на стеке его не сохранить.

Когда на стеке будет уместен, если буфер маленький (десятки–сотни байт) и нужен только внутри одной функции, а также если его размер заведомо известен.

Буфер в .bss (статический/глобальный)

Объявление такого буфера отличается совсем чуть-чуть: static char buffer[BUFFER_SIZE]; , но это “чуть-чуть” все меняет. Объявить его можно на файловом уровне или внутри функции, либо это может быть просто глобальная переменная. Память для него выделяется на этапе линковки, обнуляется один раз при старте программы (для .bss) и существует всё время работы программы.

Сильные стороны:

  • Вообще нет накладных расходов на выделение памяти: память выделена заранее, есть всегда, не нужно ни malloc, ни сдвига стека. Обращение к буферу происходит всегда по фиксированному адресу, что на некоторых архитектурах даёт более короткие инструкции.
  • Размер буфера известен на этапе компиляции, ошибок «не хватило памяти» нет по определению (либо влезло в линковку, либо нет).
  • Простота реализации: не нужно освобождать, не нужно проверять на NULL.

Слабые стороны:

  • Размер фиксирован и «оплачен» всегда. Даже если SMTP-клиент совсем не используется, буфер занимает оперативную память. Это может быть заметно, особенно если таких буферов несколько.
  • Один буфер — один экземпляр. Если нужно запустить две функции одновременно, статический буфер придётся либо дублировать, либо защищать мьютексом — и то, и другое неудобно. Глобальное выделение памяти вообще плохо масштабируется на несколько экземпляров. Если функция вызывается из двух задач, буфер будет общим, и без синхронизации вы получите гонку. На ESP32, где задачи — обычное дело, это реальный риск больших проблем.
  • Нельзя выбрать тип памяти. .bss — это всегда только внутренняя RAM, без возможности положить буфер в PSRAM или в DMA-область.

Такой вариант будет уместен, если буфер маленький, нужен часто и все время жизни программы; и при этом экземпляр один, и вы на 100500% уверены, что функция не будет вызываться из разных задач. 

 

В данном случае я использовал буфер в куче (ожидаемо). Теперь нам нужно определиться с его размером. При обмене данными с SMTP-сервером самое длинное – это отправляемое тело сообщения. Поэтому я заранее формирую тело с учетом всего возможных заголовков и <CRLF>.<CRLF>, и только после этого выделяю буфер на один байт больше (для завершающего \0). Но и это ещё не все.

Сообщение может быть очень коротким, и буфер получится небольшим. Между тем, сервер может ответить достаточно большим сообщением, да и base64 необходимо где-то кодировать. Поэтому я ограничил размер буфера “снизу” параметром CONFIG_RE_NOTIFY_SMTP_BUFFER_SIZE, который по умолчанию равен 512 байт. Это значение часто встречается в различных примерах SMTP, но вы можете его уменьшить или увеличить. 


Подготовительные работы

Прежде чем начинать какую-то суету по обмену данными с сервером, необходимо подготовить то, что будет отправлять. И использую в уведомлениях HTML-форматирование текста, так как оно отлично “ложится” на все способы доставки сообщений, которые я использую (кроме NTFY, там только markdown, но в него можно перекодировать).

Шаблон тела сообщения “зашит” в макросе:

#define SMTP_TEMPLATE_MESSAGE \
    "From: %s\r\n" \
    "To: %s\r\n" \
    "Subject: =?utf-8?B?%s?=\r\n" \
    "MIME-Version: 1.0\r\n" \
    "Content-Type: text/html; charset=utf-8\r\n" \
    "\r\n" \
    "<html><body><p>%s</p>" \
    "<p><code>%s</code></p></body></html>\r\n" \
    ".\r\n"

Из него видно, что тему сообщения мы будем отправлять в base64 и utf-8, дабы избежать проблем с кириллицей. Тело сообщения также отправляется в кодировке utf-8, но уже без base64 – первым абзацем <p>%s</p>. Снизу к нему приляпан второй абзац с отметкой времени <p><code>%s</code></p>. Финальная точка уже присутствует в шаблоне, как и все необходимые \r\n. Остается только добавить воды применить форматирование через snprintf.

У меня это выглядит так:

// Формируем метку времени
struct tm timeinfo;
char buffer_timestamp[CONFIG_RE_FORMAT_STRFTIME_BUFFER_SIZE_DTS];
localtime_r(&timestamp, &timeinfo);
strftime(buffer_timestamp, sizeof(buffer_timestamp), CONFIG_RE_FORMAT_STRFTIME_DTS, &timeinfo);

// Формируем заголовок и перекодируем его в BASE64
char *subject_str = re_smtp_get_subject(msg_type);
if (!subject_str) {
    ESP_LOGE(logTAG, "Failed to allocate buffer for subject text");
    return ESP_ERR_NO_MEM;
}

mbedtls_base64_encode(NULL, 0, &(smtp_context.subject_size), (const unsigned char *)subject_str, strlen(subject_str));
#if CONFIG_RE_STRINGS_USE_PSRAM
    smtp_context.subject_b64 = (char*)re_psram_malloc(smtp_context.subject_size + 1);
#else
    smtp_context.subject_b64 = (char*)malloc(smtp_context.subject_size + 1);
#endif
if (smtp_context.subject_b64 == NULL) {
    if (subject_str) free(subject_str);
    ESP_LOGE(logTAG, "Failed to allocate buffer for subject base64");
    return ESP_ERR_NO_MEM;
}
{
    size_t olen = 0;
    ret = mbedtls_base64_encode((unsigned char *)smtp_context.subject_b64, smtp_context.subject_size, &olen, (const unsigned char *)subject_str, strlen(subject_str));
}

// Исходная строка заголовка больше не нужна
if (subject_str) free(subject_str);
subject_str = NULL;

// Проверяем результат
if (ret != 0) {
    if (smtp_context.subject_b64) free(smtp_context.subject_b64);
    ESP_LOGE(logTAG, "mbedtls_base64_encode(subject) failed: -0x%x", -ret);
    return ESP_FAIL;
}

// Определяем размер буфера для тела сообщения
smtp_context.buffer_size = snprintf(NULL, 0, SMTP_TEMPLATE_MESSAGE, 
    CONFIG_RE_NOTIFY_SMTP_SENDER_MAIL, CONFIG_RE_NOTIFY_SMTP_RECIPIENT_MAIL, 
    smtp_context.subject_b64, message_text_in_html, buffer_timestamp);
if (smtp_context.buffer_size == 0) {
    if (smtp_context.subject_b64) free(smtp_context.subject_b64);
    ESP_LOGE(logTAG, "Failed to calculate buffer size for messase text");
    return ESP_ERR_INVALID_SIZE;
}

// Выравниваем размер и выделяем память под основной буфер
if (smtp_context.buffer_size < CONFIG_RE_NOTIFY_SMTP_BUFFER_SIZE) {
    smtp_context.buffer_size = CONFIG_RE_NOTIFY_SMTP_BUFFER_SIZE;
} else {
    smtp_context.buffer_size++;
}
#if CONFIG_RE_STRINGS_USE_PSRAM
    smtp_context.buffer = (char*)re_psram_malloc(smtp_context.buffer_size);
#else
    smtp_context.buffer = (char*)malloc(smtp_context.buffer_size);
#endif
if (smtp_context.buffer == NULL) {
    if (smtp_context.subject_b64) free(smtp_context.subject_b64);
    ESP_LOGE(logTAG, "Failed to allocate buffer for messase text");
    return ESP_ERR_NO_MEM;
}
memset(smtp_context.buffer, 0, smtp_context.buffer_size);
  • Вначале мы формируем строковую “метку времени” на классическом “мелком” буфере на стеке.
  • Затем формируем заголовок с помощью re_smtp_get_subject() – она формирует заголовок сообщения и размещает его в куче.
  • Сформированный заголовок нужно закодировать в base64. Это делается в два этапа:
    • Вначале просто определяем нужный размер буфера для кодирования
    • Выделяем память (уже в куче) и получаем заголовок в base64, который можно подставлять в тело сообщения
    • Исходная строка заголовка (не в base64) уже больше не нужна, и ее можно спокойно удалить
  • Подставляем email-ы, заголовок в base64, само сообщение, сформированный timestamp в шаблон и вычисляем размер основного буфера. Если он получается меньше 512 байт, выделяем 512 байт, иначе вычисленный размер плюс 1 байт.
  • По возможности выделяем буфер в PSRAM (если она есть), иначе в обычной DRAM.

И на забыть все это удалить в конце:

cleanup:
    // Освобождаем ресурсы
    if (smtp_context.buffer) free(smtp_context.buffer);
    if (smtp_context.subject_b64) free(smtp_context.subject_b64);

    mbedtls_ssl_close_notify(&smtp_context.ssl_context);
    mbedtls_ssl_free(&smtp_context.ssl_context);
    mbedtls_ssl_config_free(&smtp_context.ssl_config);
    mbedtls_ctr_drbg_free(&smtp_context.ctr_drbg);
    mbedtls_net_free(&(smtp_context.server_fd));
    mbedtls_entropy_free(&(smtp_context.entropy));    

 


Служебные функции

А еще нам понадобится некий набор для хранения служебных данных:

typedef struct {
    re_smtp_server_config_t *smtp_config;
    mbedtls_net_context server_fd;
    mbedtls_ssl_config ssl_config;
    mbedtls_ssl_context ssl_context;
    mbedtls_ctr_drbg_context ctr_drbg;
    mbedtls_entropy_context entropy;
    bool use_mbedtls;
    size_t subject_size; 
    char *subject_b64;
    size_t buffer_size;
    char *buffer;
    bool supports_starttls;
    bool supports_auth;
} re_smtp_mbedtls_context_t;

Для того, чтобы начинать что-то передавать, таки придется написать ещё несколько “служебных” функций. Я просто оставлю их здесь.

 

Генерация заголовка сообщения

// Генерация заголовка сообщения по шаблону, в зависимости от типа сообщения
static char* re_smtp_get_subject(const re_notify_kind_t msg_type)
{
    if (msg_type == NMK_GENERAL) {
        return re_malloc_stringf_psram(STRING_SMTP_SUBJECT_GENERAL, CONFIG_RE_NOTIFY_DEVICE_NAME);
    } else if (msg_type == NMK_ALARMS) {
        return re_malloc_stringf_psram(STRING_SMTP_SUBJECT_ALARMS, CONFIG_RE_NOTIFY_DEVICE_NAME);
    } else if (msg_type == NMK_CONFIG) {
        return re_malloc_stringf_psram(STRING_SMTP_SUBJECT_CONFIG, CONFIG_RE_NOTIFY_DEVICE_NAME);
    } else if (msg_type == NMK_SERVICE) {
        return re_malloc_stringf_psram(STRING_SMTP_SUBJECT_SERVICE, CONFIG_RE_NOTIFY_DEVICE_NAME);
    }

    return re_malloc_stringf_psram(STRING_SMTP_SUBJECT_GENERAL, CONFIG_RE_NOTIFY_DEVICE_NAME);
}

где константы в конечном итоге сводятся к строкам:

#define RU_STRING_SMTP_SUBJECT_GENERAL      "💬 Сообщение от %s"
#define RU_STRING_SMTP_SUBJECT_ALARMS       "⚠️ Важное уведомление от %s"
#define RU_STRING_SMTP_SUBJECT_CONFIG       "✳️ Изменение параметра на %s"
#define RU_STRING_SMTP_SUBJECT_SERVICE      "⚙️ Служебное сообщение от %s"

 

Установка lwIP соединения без использования mbedTLS

// Установка lwIP соединения
static esp_err_t re_smtp_connect_open(re_smtp_mbedtls_context_t *smtp_context)
{
    // Определяем IP-адрес
    struct addrinfo hints = { .ai_family = AF_INET, .ai_socktype = SOCK_STREAM };
    struct addrinfo *res;
    if (getaddrinfo((const char*)smtp_context->smtp_config->server_name, (const char*)smtp_context->smtp_config->server_port, &hints, &res) != 0) {
        ESP_LOGE(logTAG, "DNS lookup failed");
        return ESP_FAIL;
    }

    // Открываем сокет
    smtp_context->server_fd.fd = socket(res->ai_family, res->ai_socktype, 0);
    if (smtp_context->server_fd.fd < 0) {
        ESP_LOGE(logTAG, "Failed to allocate socket");
        freeaddrinfo(res);
        return ESP_FAIL;
    }

    // Подключение к серверу
    if (connect(smtp_context->server_fd.fd, res->ai_addr, res->ai_addrlen) != 0) {
        ESP_LOGE(logTAG, "Socket connect failed");
        close(smtp_context->server_fd.fd);
        freeaddrinfo(res);
        return ESP_FAIL;
    }
    freeaddrinfo(res);
    
    return ESP_OK;
}

 

Установка mbedTLS соединения

// Установка mbedTLS соединения
static esp_err_t re_smtp_connect_mbedtls(re_smtp_mbedtls_context_t *smtp_context)
{
    int ret;

    // Инициализация RNG
    if ((ret = mbedtls_ctr_drbg_seed(&(smtp_context->ctr_drbg), mbedtls_entropy_func, &(smtp_context->entropy), NULL, 0)) != 0) {
        ESP_LOGE(logTAG, "mbedtls_ctr_drbg_seed failed: -0x%x", -ret);
        return ESP_FAIL;
    }

    // Подключение к серверу
    if ((ret = mbedtls_net_connect(&(smtp_context->server_fd), 
        (const char*)smtp_context->smtp_config->server_name, 
        (const char*)smtp_context->smtp_config->server_port, 
        MBEDTLS_NET_PROTO_TCP)
    ) != 0) {
        ESP_LOGE(logTAG, "mbedtls_net_connect failed: -0x%x", -ret);
        return ESP_FAIL;
    }

    return ESP_OK;
}

 

Установка TLS соединения поверх уже открытого сокета

// Установка TLS соединения
static esp_err_t re_smtp_tls_start(re_smtp_mbedtls_context_t *smtp_context)
{
    int ret;

    // Настройка SSL-конфигурации
    if ((ret = mbedtls_ssl_config_defaults(&(smtp_context->ssl_config), MBEDTLS_SSL_IS_CLIENT, MBEDTLS_SSL_TRANSPORT_STREAM, MBEDTLS_SSL_PRESET_DEFAULT)) != 0) {
        ESP_LOGE(logTAG, "mbedtls_ssl_config_defaults failed: -0x%x", -ret);
        return ESP_FAIL;
    }

    // Включаем проверку сертификатов
    if (smtp_context->smtp_config->skip_cert_check) {
        mbedtls_ssl_conf_authmode(&(smtp_context->ssl_config), MBEDTLS_SSL_VERIFY_NONE);
    } else {
        mbedtls_ssl_conf_authmode(&(smtp_context->ssl_config), MBEDTLS_SSL_VERIFY_REQUIRED);
    }

    // Подключаем CA Cert bundle
    if ((ret = esp_crt_bundle_attach(&(smtp_context->ssl_config))) != 0) {
        ESP_LOGE(logTAG, "esp_crt_bundle_attach failed: -0x%x", -ret);
        return ESP_FAIL;
    }

    mbedtls_ssl_conf_rng(&(smtp_context->ssl_config), mbedtls_ctr_drbg_random, &(smtp_context->ctr_drbg));

    if ((ret = mbedtls_ssl_setup(&(smtp_context->ssl_context), &(smtp_context->ssl_config))) != 0) {
        ESP_LOGE(logTAG, "mbedtls_ssl_setup failed: -0x%x", -ret);
        return ESP_FAIL;
    }

    // Валидация хоста
    if ((ret = mbedtls_ssl_set_hostname(&(smtp_context->ssl_context), smtp_context->smtp_config->server_name)) != 0) {
        ESP_LOGE(logTAG, "mbedtls_ssl_set_hostname failed: -0x%x", -ret);
        return ESP_FAIL;
    }

    mbedtls_ssl_set_bio(&(smtp_context->ssl_context), &(smtp_context->server_fd), mbedtls_net_send, mbedtls_net_recv, NULL);

    // Выполнение TLS Handshake
    while ((ret = mbedtls_ssl_handshake(&(smtp_context->ssl_context))) != 0) {
        if (ret != MBEDTLS_ERR_SSL_WANT_READ && ret != MBEDTLS_ERR_SSL_WANT_WRITE) {
            ESP_LOGE(logTAG, "mbedtls_ssl_handshake failed: -0x%x", -ret);
            return ESP_FAIL;
        }
    }

    smtp_context->use_mbedtls = true;
    ESP_LOGD(logTAG, "Secure TLS connection has been established");
    return ESP_OK;
}

Пояснение: в данном случае (и в других) и использую набор сертификатов от Mozilla, но несколько урезанный, через вызов esp_crt_bundle_attach.

 

Парсинг расширений сервера (имеет смысл только для EHLO)

// Парсинг расширений сервера (имеет смысл только для EHLO)
static bool _str_startswith_ci(const char *s, size_t s_len, const char *prefix)
{
    size_t p_len = strlen(prefix);
    if (s_len < p_len) return false;
    return strncasecmp(s, prefix, p_len) == 0;
}

static void re_smtp_parse_capability(re_smtp_mbedtls_context_t *smtp_context, const char *text, size_t text_len)
{
    // Пропускаем ведущие пробелы (на случай "250-AUTH ..." — text уже без кода, но подстрахуемся)
    while (text_len > 0 && (*text == ' ' || *text == '\t')) {
        text++; text_len--;
    }
    if (text_len == 0) return;

    // STARTTLS: ищем как отдельное слово (обычно строка целиком "STARTTLS")
    static const char starttls[] = "STARTTLS";
    if (_str_startswith_ci(text, text_len, starttls)) {
        if (text_len == sizeof(starttls) - 1 ||
            text[sizeof(starttls) - 1] == ' ') {
            smtp_context->supports_starttls = true;
        }
    }

    // AUTH: строка начинается с "AUTH" и далее пробел + список механизмов
    static const char auth[] = "AUTH";
    if (_str_startswith_ci(text, text_len, auth)) {
        if (text_len > sizeof(auth) - 1 && text[sizeof(auth) - 1] == ' ') {
            smtp_context->supports_auth = true;
        }
    }
}

 

Отправляем команду или буфер

Это основная функция для общения с сервером.

// Отправить данные или команду (команда или данные уже должны быть в буфере)
static esp_err_t re_smtp_exec(re_smtp_mbedtls_context_t *smtp_context, const char* command, const char* expected_code, const bool check_capabilities)
{
    // Отправляем команду
    if (command != NULL) {
        size_t cmd_len = strlen(command);
        ESP_LOGI(logTAG, "Send to SMTP server: %.*s", (int)cmd_len-2, command);

        // Отправляем буфер
        size_t bytes_left = cmd_len;
        const char *ptr = command;
        int sent = 0;
        while (bytes_left > 0) {
            if (smtp_context->use_mbedtls) {
                sent = mbedtls_ssl_write(&(smtp_context->ssl_context), (const unsigned char*)ptr, bytes_left);
            } else {
                sent = mbedtls_net_send(&(smtp_context->server_fd), (const unsigned char*)ptr, bytes_left);
            }
            if (sent == MBEDTLS_ERR_SSL_WANT_WRITE) {
                continue;
            }
            if (sent < 0) {
                ESP_LOGE(logTAG, "Socket send failed: errno %d", -sent);
                return ESP_FAIL;
            }
            bytes_left -= sent;
            ptr += sent;
        }
    }
    
    // Получаем ответ
    size_t total_received = 0;
    bool response_complete = false;
    while (!response_complete && total_received < smtp_context->buffer_size - 1) {
        // Читаем порцию данных сокета в буфер
        int ret;
        size_t bytes_to_read = smtp_context->buffer_size - 1 - total_received;
        if (smtp_context->use_mbedtls) {
            ret = mbedtls_ssl_read(&(smtp_context->ssl_context), (unsigned char *)(smtp_context->buffer + total_received), bytes_to_read);
        } else {
            ret = mbedtls_net_recv(&(smtp_context->server_fd), (unsigned char *)(smtp_context->buffer + total_received), bytes_to_read);
        }
        if (ret == MBEDTLS_ERR_SSL_WANT_READ) {
            continue;
        }

        if (ret <= 0) {
            ESP_LOGE(logTAG, "Socket recv failed or connection closed: %d", ret);
            return ESP_FAIL;
        }

        total_received += ret;
        smtp_context->buffer[total_received] = '\0';

        // Проверяем, не завершён ли ответ: ищем в буфере последнюю строку, которая начинается с трехзначного кода и пробела
        char *line_start = smtp_context->buffer;
        char *line_end;
        while ((line_end = strstr(line_start, "\r\n")) != NULL) {
            size_t line_len = line_end - line_start;
            if (line_len >= 4) {
                // Парсим возможности (имеет смысл только для EHLO, но безвредно и для других — просто не найдёт совпадений)
                if (check_capabilities) {
                    const char *text = line_start + 4;
                    size_t text_len = line_len - 4;
                    re_smtp_parse_capability(smtp_context, text, text_len);
                }
                // Проверяем 4-й символ (индекс 3): пробел означает последнюю строку ответа
                if (line_start[3] == ' ') {
                    response_complete = true;
                    ESP_LOGI(logTAG, "Recieved from SMTP server: %.*s", (int)total_received-2, smtp_context->buffer);
                    // Проверяем, совпадает ли код в первой строке ответа с ожидаемым
                    if (strncmp(smtp_context->buffer, expected_code, 3) != 0) {
                        ESP_LOGE(logTAG, "SMTP error: expected response \"%s\", received \"%.*s\"", 
                            expected_code, (int)total_received-2, smtp_context->buffer);
                        return ESP_ERR_INVALID_RESPONSE;
                    }
                    break;
                }
            }
            line_start = line_end + 2; // пропускаем \r\n
        }
    }

    if (!response_complete) {
        ESP_LOGE(logTAG, "SMTP response incomplete or buffer full");
        return ESP_FAIL;
    }

    return ESP_OK;
}

Здесь видно, что команда или данные отправляются не из буфера, а по ссылке const char* command, зачем это нужно – станет понятно ниже. И сразу же проверяется код ответа сервера.

 

Отправить команду EHLO

Здесь формируется “корректный” аргумент в виде локального IP-адреса, как и положено по стандарту. Все это помещается в буфер и передает в предыдущую функцию.

// Отправить команду EHLO
static esp_err_t re_smtp_send_ehlo(re_smtp_mbedtls_context_t *smtp_context)
{
    if (smtp_context == NULL || smtp_context->buffer == NULL) {
        return ESP_ERR_INVALID_ARG;
    }

    esp_netif_t *netif = esp_netif_get_default_netif();
    if (netif == NULL) {
        ESP_LOGE(logTAG, "No default netif");
        return ESP_ERR_INVALID_STATE;
    }

    esp_netif_ip_info_t ip_info;
    esp_err_t err = esp_netif_get_ip_info(netif, &ip_info);
    if (err != ESP_OK) {
        ESP_LOGE(logTAG, "esp_netif_get_ip_info failed: %s", esp_err_to_name(err));
        return err;
    }

    // Проверяем, что IP реально получен (не 0.0.0.0)
    if (ip_info.ip.addr == 0) {
        ESP_LOGE(logTAG, "No IP address assigned yet");
        return ESP_ERR_INVALID_STATE;
    }

    int n = snprintf(smtp_context->buffer, smtp_context->buffer_size, "EHLO [%d.%d.%d.%d]\r\n", IP2STR(&ip_info.ip));
    if (n < 0 || (size_t)n >= smtp_context->buffer_size) {
        ESP_LOGE(logTAG, "EHLO buffer too small");
        return ESP_ERR_INVALID_SIZE;
    }

    return re_smtp_exec(smtp_context, (const char*)smtp_context->buffer, "250", true);
}

 

Отправка тела сообщения по шаблону printf

// Отправить данные по шаблону
static esp_err_t re_smtp_send_data(re_smtp_mbedtls_context_t *smtp_context, const char *expected_code, const char *template, ...)
{
    // Форматируем строку
    va_list args;
    va_start(args, template);
    vsnprintf(smtp_context->buffer, smtp_context->buffer_size, template, args);
    va_end(args);
    
    // Отправляем данные на сервер
    return re_smtp_exec(smtp_context, (const char*)smtp_context->buffer, expected_code, false);
}

 

Отправить данные, закодированные в BASE64

// Отправить данные, закодированные в BASE64
static esp_err_t re_smtp_send_base64(re_smtp_mbedtls_context_t *smtp_context, const char* data, const char* expected_code)
{
    // Перекодируем данные в base64 в основном буфере
    size_t olen = 0;
    int ret = mbedtls_base64_encode((unsigned char *)smtp_context->buffer, smtp_context->buffer_size, &olen, (const unsigned char *)data, strlen(data));
    if (ret != 0) {
        ESP_LOGE(logTAG, "mbedtls_base64_encode(buffer) failed: -0x%x", -ret);
        return ESP_FAIL;
    }

    // Добавляем в конец сформированной строки \r\n
    smtp_context->buffer[olen]     = '\r';
    smtp_context->buffer[olen + 1] = '\n';
    smtp_context->buffer[olen + 2] = '\0';

    // Отправляем данные на сервер
    return re_smtp_exec(smtp_context, (const char*)smtp_context->buffer, expected_code, false);
}

 

Однако всегда дергать re_smtp_exec оказалось не очень удобно, поэтому я написал несколько мОкросов:

// Макросы для отправки команды
#define SMTP_SEND_EHLO(ret_code) do { \
    esp_err_t _err = re_smtp_send_ehlo(&smtp_context); \
    if (_err != ESP_OK) { \
        err = ret_code; \
        goto cleanup; \
    } \
} while (0);

#define SMTP_SEND_COMMAND(command, expected_code, ret_code) do { \
    esp_err_t _err = re_smtp_exec(&smtp_context, command, expected_code, false); \
    if (_err != ESP_OK) { \
        err = ret_code; \
        goto cleanup; \
    } \
} while (0);

// Макрос для отправки заголовка
#define SMTP_SEND_DATA(expected_code, ret_code, template, ...) do { \
    esp_err_t _err = re_smtp_send_data(&smtp_context, expected_code, template, ##__VA_ARGS__); \
    if (_err != ESP_OK) { \
        err = ret_code; \
        goto cleanup; \
    } \
} while (0);

// Макрос для отправки данных в base64
#define SMTP_SEND_BASE64(command, expected_code, ret_code) do { \
    esp_err_t _err = re_smtp_send_base64(&smtp_context, command, expected_code); \
    if (_err != ESP_OK) { \
        err = ret_code; \
        goto cleanup; \
    } \
} while (0);

 


Основная часть марлезонского балета

Всё готово, таки можно уже начинать. Поехали!

Инициализируем контекст, проверяем настройки сервера

// Контекст подключения к серверу
re_smtp_mbedtls_context_t smtp_context = {0};

#define CHECK_SMTP_STRING(name, value) \
    if ((value == NULL) || (strlen(value) == 0)) { \
        ESP_LOGE(logTAG, "\"%s\" is null", name); \
        return ESP_ERR_INVALID_ARG; \
    }

// Проверка параметров сервера
smtp_context.smtp_config = config;
if (smtp_context.smtp_config == NULL) {
    ESP_LOGE(logTAG, "Server connection parameters are not specified"); \
    return ESP_ERR_INVALID_ARG; \
}

CHECK_SMTP_STRING("server_name", smtp_context.smtp_config->server_name);
CHECK_SMTP_STRING("server_port", smtp_context.smtp_config->server_port);
CHECK_SMTP_STRING("sender_email", smtp_context.smtp_config->sender_email);
CHECK_SMTP_STRING("password", smtp_context.smtp_config->password);
CHECK_SMTP_STRING("recipient_email", smtp_context.smtp_config->recipient_email);

ESP_LOGI(logTAG, "Send message: %s", message_text_in_html);
esp_err_t err = ESP_OK;
int ret = 0;

 

Формируем заголовки, оцениваем размер сообщения и выделяем буфер

Это повтор, уже обсуждалось выше.

// Формируем метку времени
struct tm timeinfo;
char buffer_timestamp[CONFIG_RE_FORMAT_STRFTIME_BUFFER_SIZE_DTS];
localtime_r(&timestamp, &timeinfo);
strftime(buffer_timestamp, sizeof(buffer_timestamp), CONFIG_RE_FORMAT_STRFTIME_DTS, &timeinfo);

// Формируем заголовок и перекодируем его в BASE64
char *subject_str = re_smtp_get_subject(msg_type);
if (!subject_str) {
    ESP_LOGE(logTAG, "Failed to allocate buffer for subject text");
    return ESP_ERR_NO_MEM;
}

mbedtls_base64_encode(NULL, 0, &(smtp_context.subject_size), (const unsigned char *)subject_str, strlen(subject_str));
#if CONFIG_RE_STRINGS_USE_PSRAM
    smtp_context.subject_b64 = (char*)re_psram_malloc(smtp_context.subject_size + 1);
#else
    smtp_context.subject_b64 = (char*)malloc(smtp_context.subject_size + 1);
#endif
if (smtp_context.subject_b64 == NULL) {
    if (subject_str) free(subject_str);
    ESP_LOGE(logTAG, "Failed to allocate buffer for subject base64");
    return ESP_ERR_NO_MEM;
}
{
    size_t olen = 0;
    ret = mbedtls_base64_encode((unsigned char *)smtp_context.subject_b64, smtp_context.subject_size, &olen, (const unsigned char *)subject_str, strlen(subject_str));
}

// Исходная строка заголовка больше не нужна
if (subject_str) free(subject_str);
subject_str = NULL;

// Проверяем результат
if (ret != 0) {
    if (smtp_context.subject_b64) free(smtp_context.subject_b64);
    ESP_LOGE(logTAG, "mbedtls_base64_encode(subject) failed: -0x%x", -ret);
    return ESP_FAIL;
}

// Определяем размер буфера для тела сообщения
smtp_context.buffer_size = snprintf(NULL, 0, SMTP_TEMPLATE_MESSAGE, 
    CONFIG_RE_NOTIFY_SMTP_SENDER_MAIL, CONFIG_RE_NOTIFY_SMTP_RECIPIENT_MAIL, 
    smtp_context.subject_b64, message_text_in_html, buffer_timestamp);
if (smtp_context.buffer_size == 0) {
    if (smtp_context.subject_b64) free(smtp_context.subject_b64);
    ESP_LOGE(logTAG, "Failed to calculate buffer size for messase text");
    return ESP_ERR_INVALID_SIZE;
}

// Выравниваем размер и выделяем память под основной буфер
if (smtp_context.buffer_size < CONFIG_RE_NOTIFY_SMTP_BUFFER_SIZE) {
    smtp_context.buffer_size = CONFIG_RE_NOTIFY_SMTP_BUFFER_SIZE;
} else {
    smtp_context.buffer_size++;
}
#if CONFIG_RE_STRINGS_USE_PSRAM
    smtp_context.buffer = (char*)re_psram_malloc(smtp_context.buffer_size);
#else
    smtp_context.buffer = (char*)malloc(smtp_context.buffer_size);
#endif
if (smtp_context.buffer == NULL) {
    if (smtp_context.subject_b64) free(smtp_context.subject_b64);
    ESP_LOGE(logTAG, "Failed to allocate buffer for messase text");
    return ESP_ERR_NO_MEM;
}
memset(smtp_context.buffer, 0, smtp_context.buffer_size);

 

Инициализация структур mbedTLS

// Инициализация структур mbedTLS
mbedtls_net_init(&smtp_context.server_fd);
mbedtls_ssl_init(&smtp_context.ssl_context);
mbedtls_ssl_config_init(&smtp_context.ssl_config);
mbedtls_ctr_drbg_init(&smtp_context.ctr_drbg);
mbedtls_entropy_init(&smtp_context.entropy);

 

Подключение к серверу

// Подключение к серверу (без установки непосредственного TLS-соединения)
err = re_smtp_connect_mbedtls(&smtp_context);
if (err != ESP_OK) {
    ESP_LOGE(logTAG, "Failed to connect to SMTP server");
    goto cleanup;
}

 

Настройка TLS соединения

Если мы подключаемся к серверу через “обычное” TLS-соединение, можно это сделать:

// Настройка TLS соединения
if (smtp_context.smtp_config->encryption == SMTP_ENCRYPTION_TLS) {
    err = re_smtp_tls_start(&smtp_context);
    if (err != ESP_OK) {
        ESP_LOGE(logTAG, "Failed to TLS handshake with SMTP server");
        goto cleanup;
    }
}

 

Обмен с сервером

Ну а дальше начинается непосредственный обмен с сервером, который я описывал все это время:

smtp_context.supports_auth = false;
smtp_context.supports_starttls = false;

// Начало диалога с SMTP сервером
SMTP_SEND_COMMAND(NULL, "220", ESP_FAIL);

// Отправляем приветствие
SMTP_SEND_EHLO(ESP_FAIL);

// Процедура переключения на шифрование для STARTTLS
if ((smtp_context.smtp_config->encryption == SMTP_ENCRYPTION_STARTTLS) 
    || ((smtp_context.smtp_config->encryption == SMTP_ENCRYPTION_AUTO) && smtp_context.supports_starttls)) {
    // Отправляем команду STARTTLS
    SMTP_SEND_COMMAND("STARTTLS\r\n", "220", ESP_FAIL); 

    // Настройка TLS соединения 
    err = re_smtp_tls_start(&smtp_context);
    if (err != ESP_OK) {
        ESP_LOGE(logTAG, "Failed to TLS handshake with SMTP server");
        goto cleanup;
    }

    // Повторный EHLO уже внутри защищенного TLS канала (требование RFC 3207)
    SMTP_SEND_EHLO(ESP_FAIL);
}

// Запрос авторизации
if (smtp_context.supports_auth) {
    SMTP_SEND_COMMAND("AUTH LOGIN\r\n", "334", ESP_FAIL);

    // Логин (Base64)
    if (smtp_context.smtp_config->login) {
        SMTP_SEND_BASE64(smtp_context.smtp_config->login, "334", ESP_ERR_INVALID_RESPONSE);
    } else {
        SMTP_SEND_BASE64(smtp_context.smtp_config->sender_email, "334", ESP_ERR_INVALID_RESPONSE);
    }
    
    // Пароль (Base64)
    SMTP_SEND_BASE64(smtp_context.smtp_config->password, "235", ESP_ERR_INVALID_RESPONSE);
}

// Отправитель
SMTP_SEND_DATA("250", ESP_ERR_INVALID_ARG, "MAIL FROM:<%s>\r\n", smtp_context.smtp_config->sender_email);

// Получатель
SMTP_SEND_DATA("250", ESP_ERR_INVALID_ARG, "RCPT TO:<%s>\r\n", smtp_context.smtp_config->recipient_email);

// Переход в режим передачи данных
SMTP_SEND_COMMAND("DATA\r\n", "354", ESP_ERR_NOT_FINISHED);

// Письмо (Заголовки RFC 5322 + тело + финальная точка)
SMTP_SEND_DATA("250", ESP_ERR_INVALID_ARG, SMTP_TEMPLATE_MESSAGE, 
    smtp_context.smtp_config->sender_email, 
    smtp_context.smtp_config->recipient_email, 
    smtp_context.subject_b64,
    message_text_in_html, 
    buffer_timestamp);

// Выход
SMTP_SEND_COMMAND("QUIT\r\n", "221", ESP_ERR_NOT_FINISHED);

ESP_LOGI(logTAG, "Email successfully sent");

 

Завершение работы 

В конце необходимо удалить из кучи все, что мы навыделяли:

cleanup:
    // Чистим каки
    if (smtp_context.buffer) free(smtp_context.buffer);
    if (smtp_context.subject_b64) free(smtp_context.subject_b64);

    mbedtls_ssl_close_notify(&smtp_context.ssl_context);
    mbedtls_ssl_free(&smtp_context.ssl_context);
    mbedtls_ssl_config_free(&smtp_context.ssl_config);
    mbedtls_ctr_drbg_free(&smtp_context.ctr_drbg);
    mbedtls_net_free(&(smtp_context.server_fd));
    mbedtls_entropy_free(&(smtp_context.entropy));    

Ну вот, в общем-то и все!

Целиком это выглядит довольно громоздко:

// Отправка сообщения
esp_err_t re_smtp_send_message(re_smtp_server_config_t *config, const re_notify_kind_t msg_type, char* message_text_in_html, time_t timestamp)
{
    // Контекст подключения к серверу
    re_smtp_mbedtls_context_t smtp_context = {0};

    #define CHECK_SMTP_STRING(name, value) \
        if ((value == NULL) || (strlen(value) == 0)) { \
            ESP_LOGE(logTAG, "\"%s\" is null", name); \
            return ESP_ERR_INVALID_ARG; \
        }

    // Проверка параметров сервера
    smtp_context.smtp_config = config;
    if (smtp_context.smtp_config == NULL) {
        ESP_LOGE(logTAG, "Server connection parameters are not specified"); \
        return ESP_ERR_INVALID_ARG; \
    }

    CHECK_SMTP_STRING("server_name", smtp_context.smtp_config->server_name);
    CHECK_SMTP_STRING("server_port", smtp_context.smtp_config->server_port);
    CHECK_SMTP_STRING("sender_email", smtp_context.smtp_config->sender_email);
    CHECK_SMTP_STRING("password", smtp_context.smtp_config->password);
    CHECK_SMTP_STRING("recipient_email", smtp_context.smtp_config->recipient_email);

    ESP_LOGI(logTAG, "Send message: %s", message_text_in_html);
    esp_err_t err = ESP_OK;
    int ret = 0;

    // Формируем метку времени
    struct tm timeinfo;
    char buffer_timestamp[CONFIG_RE_FORMAT_STRFTIME_BUFFER_SIZE_DTS];
    localtime_r(&timestamp, &timeinfo);
    strftime(buffer_timestamp, sizeof(buffer_timestamp), CONFIG_RE_FORMAT_STRFTIME_DTS, &timeinfo);

    // Формируем заголовок и перекодируем его в BASE64
    char *subject_str = re_smtp_get_subject(msg_type);
    if (!subject_str) {
        ESP_LOGE(logTAG, "Failed to allocate buffer for subject text");
        return ESP_ERR_NO_MEM;
    }

    mbedtls_base64_encode(NULL, 0, &(smtp_context.subject_size), (const unsigned char *)subject_str, strlen(subject_str));
    #if CONFIG_RE_STRINGS_USE_PSRAM
        smtp_context.subject_b64 = (char*)re_psram_malloc(smtp_context.subject_size + 1);
    #else
        smtp_context.subject_b64 = (char*)malloc(smtp_context.subject_size + 1);
    #endif
    if (smtp_context.subject_b64 == NULL) {
        if (subject_str) free(subject_str);
        ESP_LOGE(logTAG, "Failed to allocate buffer for subject base64");
        return ESP_ERR_NO_MEM;
    }
    {
        size_t olen = 0;
        ret = mbedtls_base64_encode((unsigned char *)smtp_context.subject_b64, smtp_context.subject_size, &olen, (const unsigned char *)subject_str, strlen(subject_str));
    }

    // Исходная строка заголовка больше не нужна
    if (subject_str) free(subject_str);
    subject_str = NULL;

    // Проверяем результат
    if (ret != 0) {
        if (smtp_context.subject_b64) free(smtp_context.subject_b64);
        ESP_LOGE(logTAG, "mbedtls_base64_encode(subject) failed: -0x%x", -ret);
        return ESP_FAIL;
    }

    // Определяем размер буфера для тела сообщения
    smtp_context.buffer_size = snprintf(NULL, 0, SMTP_TEMPLATE_MESSAGE, 
        CONFIG_RE_NOTIFY_SMTP_SENDER_MAIL, CONFIG_RE_NOTIFY_SMTP_RECIPIENT_MAIL, 
        smtp_context.subject_b64, message_text_in_html, buffer_timestamp);
    if (smtp_context.buffer_size == 0) {
        if (smtp_context.subject_b64) free(smtp_context.subject_b64);
        ESP_LOGE(logTAG, "Failed to calculate buffer size for messase text");
        return ESP_ERR_INVALID_SIZE;
    }

    // Выравниваем размер и выделяем память под основной буфер
    if (smtp_context.buffer_size < CONFIG_RE_NOTIFY_SMTP_BUFFER_SIZE) {
        smtp_context.buffer_size = CONFIG_RE_NOTIFY_SMTP_BUFFER_SIZE;
    } else {
        smtp_context.buffer_size++;
    }
    #if CONFIG_RE_STRINGS_USE_PSRAM
        smtp_context.buffer = (char*)re_psram_malloc(smtp_context.buffer_size);
    #else
        smtp_context.buffer = (char*)malloc(smtp_context.buffer_size);
    #endif
    if (smtp_context.buffer == NULL) {
        if (smtp_context.subject_b64) free(smtp_context.subject_b64);
        ESP_LOGE(logTAG, "Failed to allocate buffer for messase text");
        return ESP_ERR_NO_MEM;
    }
    memset(smtp_context.buffer, 0, smtp_context.buffer_size);

    // Инициализация структур mbedTLS
    mbedtls_net_init(&smtp_context.server_fd);
    mbedtls_ssl_init(&smtp_context.ssl_context);
    mbedtls_ssl_config_init(&smtp_context.ssl_config);
    mbedtls_ctr_drbg_init(&smtp_context.ctr_drbg);
    mbedtls_entropy_init(&smtp_context.entropy);

    // Подключение к серверу (без установки непосредственного TLS-соединения)
    err = re_smtp_connect_mbedtls(&smtp_context);
    if (err != ESP_OK) {
        ESP_LOGE(logTAG, "Failed to connect to SMTP server");
        goto cleanup;
    }

    // Настройка TLS соединения
    if (smtp_context.smtp_config->encryption == SMTP_ENCRYPTION_TLS) {
        err = re_smtp_tls_start(&smtp_context);
        if (err != ESP_OK) {
            ESP_LOGE(logTAG, "Failed to TLS handshake with SMTP server");
            goto cleanup;
        }
    }

    // Макросы для отправки команды
    #define SMTP_SEND_EHLO(ret_code) do { \
        esp_err_t _err = re_smtp_send_ehlo(&smtp_context); \
        if (_err != ESP_OK) { \
            err = ret_code; \
            goto cleanup; \
        } \
    } while (0);

    #define SMTP_SEND_COMMAND(command, expected_code, ret_code) do { \
        esp_err_t _err = re_smtp_exec(&smtp_context, command, expected_code, false); \
        if (_err != ESP_OK) { \
            err = ret_code; \
            goto cleanup; \
        } \
    } while (0);

    // Макрос для отправки заголовка
    #define SMTP_SEND_DATA(expected_code, ret_code, template, ...) do { \
        esp_err_t _err = re_smtp_send_data(&smtp_context, expected_code, template, ##__VA_ARGS__); \
        if (_err != ESP_OK) { \
            err = ret_code; \
            goto cleanup; \
        } \
    } while (0);

    // Макрос для отправки данных в base64
    #define SMTP_SEND_BASE64(command, expected_code, ret_code) do { \
        esp_err_t _err = re_smtp_send_base64(&smtp_context, command, expected_code); \
        if (_err != ESP_OK) { \
            err = ret_code; \
            goto cleanup; \
        } \
    } while (0);

    smtp_context.supports_auth = false;
    smtp_context.supports_starttls = false;

    // Начало диалога с SMTP сервером
    SMTP_SEND_COMMAND(NULL, "220", ESP_FAIL);

    // Отправляем приветствие
    SMTP_SEND_EHLO(ESP_FAIL);

    // Процедура переключения на шифрование для STARTTLS
    if ((smtp_context.smtp_config->encryption == SMTP_ENCRYPTION_STARTTLS) 
        || ((smtp_context.smtp_config->encryption == SMTP_ENCRYPTION_AUTO) && smtp_context.supports_starttls)) {
        // Отправляем команду STARTTLS
        SMTP_SEND_COMMAND("STARTTLS\r\n", "220", ESP_FAIL); 

        // Настройка TLS соединения 
        err = re_smtp_tls_start(&smtp_context);
        if (err != ESP_OK) {
            ESP_LOGE(logTAG, "Failed to TLS handshake with SMTP server");
            goto cleanup;
        }

        // Повторный EHLO уже внутри защищенного TLS канала (требование RFC 3207)
        SMTP_SEND_EHLO(ESP_FAIL);
    }

    // Запрос авторизации
    if (smtp_context.supports_auth) {
        SMTP_SEND_COMMAND("AUTH LOGIN\r\n", "334", ESP_FAIL);

        // Логин (Base64)
        if (smtp_context.smtp_config->login) {
            SMTP_SEND_BASE64(smtp_context.smtp_config->login, "334", ESP_ERR_INVALID_RESPONSE);
        } else {
            SMTP_SEND_BASE64(smtp_context.smtp_config->sender_email, "334", ESP_ERR_INVALID_RESPONSE);
        }
        
        // Пароль (Base64)
        SMTP_SEND_BASE64(smtp_context.smtp_config->password, "235", ESP_ERR_INVALID_RESPONSE);
    }

    // Отправитель
    SMTP_SEND_DATA("250", ESP_ERR_INVALID_ARG, "MAIL FROM:<%s>\r\n", smtp_context.smtp_config->sender_email);

    // Получатель
    SMTP_SEND_DATA("250", ESP_ERR_INVALID_ARG, "RCPT TO:<%s>\r\n", smtp_context.smtp_config->recipient_email);

    // Переход в режим передачи данных
    SMTP_SEND_COMMAND("DATA\r\n", "354", ESP_ERR_NOT_FINISHED);
    
    // Письмо (Заголовки RFC 5322 + тело + финальная точка)
    SMTP_SEND_DATA("250", ESP_ERR_INVALID_ARG, SMTP_TEMPLATE_MESSAGE, 
        smtp_context.smtp_config->sender_email, 
        smtp_context.smtp_config->recipient_email, 
        smtp_context.subject_b64,
        message_text_in_html, 
        buffer_timestamp);

    // Выход
    SMTP_SEND_COMMAND("QUIT\r\n", "221", ESP_ERR_NOT_FINISHED);
    
    ESP_LOGI(logTAG, "Email successfully sent");

cleanup:
    // Освобождаем ресурсы
    if (smtp_context.buffer) free(smtp_context.buffer);
    if (smtp_context.subject_b64) free(smtp_context.subject_b64);

    mbedtls_ssl_close_notify(&smtp_context.ssl_context);
    mbedtls_ssl_free(&smtp_context.ssl_context);
    mbedtls_ssl_config_free(&smtp_context.ssl_config);
    mbedtls_ctr_drbg_free(&smtp_context.ctr_drbg);
    mbedtls_net_free(&(smtp_context.server_fd));
    mbedtls_entropy_free(&(smtp_context.entropy));    

    return err;
}

 

Ах, да…. Если вы пожелаете повторить накарябанное, то не забудьте про заголовки:

#include <strings.h>
#include "sdkconfig.h"
#include "esp_crt_bundle.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "lwip/netdb.h"
#include "mbedtls/error.h"
#include "mbedtls/net_sockets.h"
#include "mbedtls/base64.h"
#include "mbedtls/esp_config.h"
#include "mbedtls/ssl.h"
#include "mbedtls/entropy.h"
#include "mbedtls/ctr_drbg.h"
#include "esp_crt_bundle.h"

 

Конечно, я использовал некоторые “свои” библиотечные функции, но их легко заменить на стандартные.


Известные проблемы

Стоит остановится на известных проблемах приведенного выше кода.

1. Данный код “не умеет” отправлять сообщение нескольким получателям сразу, например: addres1@mail.ru;addres2@mail.ru. Для этого пришлось бы парсить адресную строку и в цикле отправлять получателей сообщения. Но мне это пока не требуется, а время тратить не хочется. Поэтому пока оставил “как есть”.

2. Данный код не обрабатывает все сообщения сервера по стандарту. Если на каком-то этапе сервер возвращает ошибку, то процесс просто прерывается. Меня это пока устраивает.

3. По стандарту, если строка сообщения начинается с точки, то её нужно “экранировать” второй точкой .., в функции это никак не обрабатывается. Просто учтите это, когда будете отправлять сообщения.

На GitHub эта библиотека пока не выложена, так как является частью одного большого “комплекта”, который пока не готов, и я не хочу его выкладывать в общий доступ в откровенно сыром виде. Но будет, надеюсь.

 


Параметры отправки почты через сервера Яндекса

Если вы попытаетесь использовать сервер smtp.yandex.ru, то вам придется учесть следующее.

  • “Общее” публичное имя сервера – smtp.yandex.ru, но персонально вам ответит один из многих серверов, например mail-nwsmtp-smtp-production-main-81.
  • Порт – либо 587 (STARTTLS) либо 465 (TLS), открытые соединения не допускаются
  • В качестве логина необходимо отправить либо имя учетной записи, либо электронный адрес полностью.
  • А вот основной пароль от аккаунта Яндекса сервер не примет. Вам необходимо зайти в Настройки почты → Безопасность → кликнуть по ссылке “пароли приложений” и создать пароль приложений с доступом к SMTP и POP3. Работать он будет через пару часов после создания, поэтому нужно будет подождать.
  • Иногда этого не достаточно. Если вы получили ответ сервера 535 5.7.8 error: authentication failed: this user does not have access rights to this service, а с паролем приложения все в порядке, откройте Настройки почты → Почтовые программы и разрешите доступ к данным почты как минимум по протоколу POP3 (это также включает доступ и к SMTP).

 


Ссылки

Оцените по шкале от 1 до 5 - насколько эта статья была Вам полезна?
[ 5 из 5, всего 1 оценок ]

-= Каталог статей (список по разделам) =-   -= Архив статей (плитки, все подряд) =-

Небольшой анонимный опрос: какие статьи вы предпочитаете?

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

  ×  5  =  15