惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

小众软件
小众软件
IT之家
IT之家
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
Security Archives - TechRepublic
Security Archives - TechRepublic
P
Proofpoint News Feed
C
CERT Recently Published Vulnerability Notes
阮一峰的网络日志
阮一峰的网络日志
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
The Cloudflare Blog
P
Palo Alto Networks Blog
Know Your Adversary
Know Your Adversary
D
Darknet – Hacking Tools, Hacker News & Cyber Security
Cisco Talos Blog
Cisco Talos Blog
L
Lohrmann on Cybersecurity
AWS News Blog
AWS News Blog
J
Java Code Geeks
博客园_首页
Scott Helme
Scott Helme
WordPress大学
WordPress大学
有赞技术团队
有赞技术团队
T
The Exploit Database - CXSecurity.com
Security Latest
Security Latest
V
Visual Studio Blog
Cloudbric
Cloudbric
Jina AI
Jina AI
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
博客园 - 叶小钗
Apple Machine Learning Research
Apple Machine Learning Research
博客园 - 聂微东
人人都是产品经理
人人都是产品经理
A
Arctic Wolf
C
Cybersecurity and Infrastructure Security Agency CISA
S
SegmentFault 最新的问题
The Last Watchdog
The Last Watchdog
SecWiki News
SecWiki News
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
W
WeLiveSecurity
K
Kaspersky official blog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Hacker News: Ask HN
Hacker News: Ask HN
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
宝玉的分享
宝玉的分享
Hugging Face - Blog
Hugging Face - Blog
量子位
Google Online Security Blog
Google Online Security Blog
博客园 - Franky
Simon Willison's Weblog
Simon Willison's Weblog
博客园 - 三生石上(FineUI控件)
Recent Commits to openclaw:main
Recent Commits to openclaw:main

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
iOS: отправляем почту по SMTP с нуля на сокетах
Влад Яндола · 2026-06-11 · via Все публикации подряд на Хабре

17 мин

4.7K

Несмотря на активное использование мессенджеров, электронная почта все еще занимает весомую долю в коммуникации, особенно в рабочей среде, из-за этого не исключена необходимость в отправке почтовых сообщений прямо с iOS приложения.

В данной статье мы ознакомимся с протоколами, по которым работает почта. Сфокусируемся на реализации протокола для отправки почтовых сообщений (SMTP) на низком уровне (BSD сокеты). И, используя собственный сетевой слой для работы с почтой, реализуем iOS клиент для отправки почтовых сообщений через любые почтовые провайдеры (ex: gmail, yandex, mail).

Оглавление

  1. По каким протоколам работает электронная почта?

  2. Разбираем протокол SMTP через терминал

  3. Пишем iOS реализацию по работе с SMTP

  4. Результат

  5. Полезные ресурсы

Эту статью можно воспринимать обособленно, но в ней я не буду детально останавливаться на слоях сетевого стека, TCP/UDP сокетах, интерфейсе BSD сокетов (в основе которых будет итоговая реализация). Всех этих аспектов я уже касался в статье про самописный HTTP сервер на сокетах. Эту статью можно воспринимать как некого рода продолжение изучения реализации "с нуля" сетевых протоколов Application уровня на swift. В прошлой части сделали HTTP, тут будем работать с SMTP, о котором и будет дальнейшее повествование.

По каким протоколам работает электронная почта?

Работая с мобильными клиент-серверными приложениями в качестве протокола application слоя мы чаще всего работаем с HTTPS. Этот протокол позволяет как получать данные с сервера, так и отправлять данные ему же. С почтой все немного сложнее.

На сегодняшний день существует три основных протокола по работе с почтой: SMTP, POP3 и IMAP. POP3 и IMAP - это протоколы только для получения почтовых сообщений с сервера. SMTP же - протокол для отправки почтовых сообщений.

  • POP3. Рассматривая протоколы получения, POP3 появился первым, но сегодня считается более устаревшим и менее функциональным, из-за этого он теряет свои позиции и практически нигде не используется. POP3 проектировался для перекачки сообщений с сервера на клиент. Тогда еще у каждого человека не было десятка девайсов, между которыми требовалась синхронизация, из-за этого протокол лишен множества необходимых на сегодняшний день функций (например, возможность пометить сообщение как прочитанное).

  • IMAP. Современная замена POP3, спроектированная под хранение почты на сервере и получение сообщений с множества устройств, с различными возможностями настройки (работа с папками, флаги сообщений, ...).

  • SMTP. Протокол только для отправки почтовых сообщений (от клиента к серверу, либо между серверами). Тут нет разделения, как с протоколами получения. Используется широко по сегодняшний день.

Для реализации полноценного почтового клиента требуется связка протоколов IMAP/SMTP. Мы сфокусируемся только на отправке, соответственно нам будет интересен только SMTP.

Перед тем как мы уйдем в реализацию, стоит подсветить альтернативы решаемой задаче:

  1. MFMailComposeViewController - готовое решение, позволяющее отобразить пользователю UI родной iOS почты, через который пользователь может отправить сообщение. Простота использования - очевидный плюс, нулевая гибкость - минус.

  2. Готовая библиотека, реализующая SMTP, например SwiftSMTP. Чуть сложнее для использования (по сравнению с первым вариантом), но полная гибкость для реализации. Из минусов - необходимость в запросе логина и пароля приложения для доступа к почтовому серверу.

  3. Своя реализация SMTP. Плюсы и минусы аналогичны пункту 2, за исключением того, что реализацию необходимо написать самому. Подойдет, если по каким-то причинам нельзя использовать third-party библиотеки (или если вы хотите разобраться глубже в том как это все работает).

Для классических сценариев по типу "нажмите сюда, чтоб написать на почту поддержки" достаточно варианта 1. Для каких-то более продвинутых интеграций с почтой потребуется выбрать между 2 и 3 вариантом. Ниже мы пойдем путем своей реализации.

Разбираем протокол SMTP через терминал

Перед реализацией нужно понять, по каким правилам вообще строится коммуникация по этому протоколу. Буду иногда ссылаться на HTTP, так как с ним большее количество разработчиков знакомы. Полное описание SMTP протокола можно почитать в RFC 5321.

Взаимодействие, как и по HTTP, осуществляется посредством команд. Фундаментальное отличие от HTTP заключается в том, что в SMTP для отправки одного письма необходимо выполнять ряд команд в определенной последовательности (транзакционная модель). А в HTTP каждый запрос независим и обрабатывается отдельно.

Давайте руками пройдем всю последовательность команд для отправки сообщения. Пока у нас нет написанного кода на swift - будем тестировать SMTP команды в терминале:

С помощью команды openssl s_client -connect smtp.gmail.com:465 -crlf -quiet устанавливаем соединение с SMTP сервером gmail. Стоит сразу сказать, что мы фактически пользуемся SMTP поверх TLS. SMTP без TLS шифрования современные серверы уже практически не поддерживают (аналогия с HTTP и HTTPS).

openssl connect

openssl connect

Запрос и ответ текстом
openssl s_client -connect smtp.gmail.com:465 -crlf -quiet
Connecting to 2607:f8b0:4002:c0f::6d
depth=2 C=US, O=Google Trust Services LLC, CN=GTS Root R4
verify return:1
depth=1 C=US, O=Google Trust Services, CN=WE2
verify return:1
depth=0 CN=smtp.gmail.com
verify return:1
220 smtp.gmail.com ESMTP 956f58d0204a3-660d64ba680sm4086602d50.21 - gsmtp

Тут мы можем сразу увидеть формат ответа сервера (выделено зеленым). Он достаточно простой, имеет универсальный формат:

XXX[' ' or '-']TEXT

Где XXX - это код ответа (аналогично HTTP status code 200, 404 и так далее), где есть определенный заложенный набор значений. Тут мы видим, например, 220, который означает Service ready. И все коды, начинающиеся с 2xx, считаются успешными ответами. Список SMTP response codes.

После кода идет либо пробел, либо тире. Пробел означает, что это последняя строчка респонса, а тире - что ответ многострочный, и далее будут еще строки (пока не дойдем до строки с пробелом после кода). После этого уже идет просто человеко-читаемый произвольный статус. Это весь формат ответа.

Далее мы обязаны представиться серверу, используя команду EHLO {name}. Name по RFC должен соответствовать доменному имени, но на практике туда можно отправить произвольное имя, обычно это не валидируется (но где-то может). Каждая команда должна заканчиваться CRLF (\r\n), на скринах с терминала этого видно не будет, но в коде нужно не забывать.

Вместо EHLO можно использовать альтернативу HELO, но RFC требует использование именно первой. SMTP сервер от Mail, например, не обработает последовательность команд, если начать с HELO. Но некоторые серверы работают и с HELO, например gmail.

EHLO

EHLO

Запрос и ответ текстом
EHLO ios-smtp-client
250-smtp.gmail.com at your service
250-SIZE 35882577
250-8BITMIME
250-AUTH LOGIN PLAIN XOAUTH2 PLAIN-CLIENTTOKEN OAUTHBEARER XOAUTH
250-ENHANCEDSTATUSCODES
250-PIPELINING
250-CHUNKING
250 SMTPUTF8

На примере этой команды как раз можно увидеть многострочный ответ сервера. Как и писал до этого, после кода идет -, на последней строке из ответа после кода пробел. Знак того, что это последняя строка ответа.

Вместе с EHLO сервер прислал некоторую информацию о себе (SMTP extensions). Например, SIZE - максимальный размер письма, которое примет сервер. AUTH - поддерживаемые способы аутентификации. PIPELINING - возможность отправить несколько команд сразу (этого еще коснемся позже). Со списком можно ознакомиться тут.

Далее инициируем процесс авторизации с помощью команды AUTH LOGIN. Способов авторизации тоже достаточно, почитать можно тут. И сервер нам прислал выше список поддерживаемых. Мы воспользуемся самым ходовым, требующим логин от почты и пароль приложения. Если настраивали нативную почту в macOS, или какой-то кастомный почтовый клиент - сталкивались с этим.

Для gmail пароль приложения можно создать тут

Для Яндекса тут (обязательно выбрать тип приложения "почта", там как раз перечислены нужные протоколы)

Для mail тут (при создании достаточно выбрать "только отправка писем", так как мы используем только SMTP)

AUTH LOGIN

AUTH LOGIN

Запрос и ответ текстом
AUTH LOGIN
334 VXNlcm5hbWU6
aHR0cHM6Ly93d3cueW91dHViZS5jb20vd2F0Y2g/dj1kUXc0dzlXZ1hjUQ==
334 UGFzc3dvcmQ6
aHR0cHM6Ly93d3cueW91dHViZS5jb20vd2F0Y2g/dj1kUXc0dzlXZ1hjUQ==
235 2.7.0 Accepted

После команды авторизации видим код 334 (код, сигнализирующий о том, что сервер принял команду и ожидает данные). Дальше идет каша из символов, но на самом деле это просто закодированное в Base64 сообщение. В первом ответе (VXNlcm5hbWU6) оно декодируется как Username:, во втором (UGFzc3dvcmQ6) Password:. Отправлять серверу логин и пароль нужно тоже в base64. Если что это не для безопасности. SMTP - 7-битный ASCII протокол, и Base64 кодирует данные в совместимый с ним формат. В конце мы видим 235 2.7.0 Accepted. Авторизация успешна.

Далее приступаем к отправке письма. Первым делом нужно сказать серверу от кого письмо с помощью команды MAIL FROM:<MAIL> (угловые скобки обязательны по RFC, без них тот же gmail дропает ошибку, но некоторые серверы не требуют). Значение почты должно быть то, с которого вы логинились. От чужой почты отправить сообщение сервер не даст (к сожалению).

MAIL FROM

MAIL FROM

Запрос и ответ текстом
MAIL FROM:<youremail@gmail.com>
250 2.1.0 OK 956f58d0204a3-660d64ba680sm4086602d50.21 - gsmtp

Далее вводим получателя, с помощью команды RCPT TO:<MAIL>. Можно вводить сколько угодно подряд раз, если получателей несколько.

RCPT TO

RCPT TO

Запрос и ответ текстом
RCPT TO:<recipientemail@yandex.ru>
250 2.1.5 OK 956f58d0204a3-660d64ba680sm4086602d50.21 - gsmtp

Финальный шаг, осталось отправить само сообщение. Для этого используется команда DATA, после нее можем вводить произвольное кол-во строк. В качестве триггера завершения ввода используется символ точки .

DATA

DATA

Запрос и ответ текстом
DATA
354 Go ahead 956f58d0204a3-660d64ba680sm4086602d50.21 - gsmtp
HELLO WORLD
.
250 2.0.0 OK  1780590458 956f58d0204a3-660d64ba680sm4086602d50.21 - gsmtp

По результату видим 250. Поздравляю, мы отправили письмо. SMTP выглядит архаично, сравнивая с HTTP, где нам бы понадобился для отправки всего один запрос, в который мы укомплектовали бы все данные (и многие почтовые сервисы предоставляют такой API, например google). Это удобно, если вам нужен клиент для одного почтового сервиса. Но все равно под капотом это все сводится к SMTP, так как это уже устоявшийся стандарт работы почтовых серверов. SMTP тоже не стоит на месте, выше я упоминал про PIPELINING, который как раз и позволяет укомплектовывать несколько команд в один запрос.

Пишем iOS реализацию по работе с SMTP

Перейдем непосредственно к реализации. SMTP, как и HTTP, работает поверх TCP. Соответственно для реализации нам потребуется класс для работы с TCP сокетами на уровне клиента. Реализация будет основана на BSD сокетах. Основополагающими винтиками в реализации будут 2 объекта:

  1. TCPConnection: инкапсулирует логику по работе с TCP сокетом. Создание соединения, чтение данных из сокета, запись в сокет и взаимодействие с TLS соединением (которое будет в виде отдельного объекта).

  2. TLSConnection: основывается на Security framework'е от Apple и их готовых функциях для криптографии. Этот класс нужен, чтоб зашифровать данные, отправляемые через TCPConnection (и дешифровывать то что приходит из него). Мы будем отправлять запросы на реальные SMTP серверы, для общения с ними мы обязаны использовать шифрование. Класс оборачивает чтение из сокета шифрованием через SSLContext, достаточно хорошо ложится на решение (правда считается deprecated, apple пушат использовать их высокоуровневый Network framework, в который уже заложено шифрование).

Благодаря этим двум классам мы умеем безопасно получать и передавать данные по TCP сокету. Чего в целом достаточно для реализации любого Application протокола, работающего поверх TCP (в том числе HTTP).

Теперь приступим к реализации SMTP слоя. Начнем с моделей. Для начала нам нужна сущность, в которой будут храниться креды для доступа к почтовому сервису:

import Foundation

struct SMTPCredentials {
  // 1
  let host: String
  let proto: Proto
  let username: String
  let loadPassword: () throws -> Data

  var port: UInt16 { proto.port }

  init(
    host: String,
    proto: Proto = .smtps,
    username: String,
    loadPassword: @escaping () throws -> Data
  ) {
    self.host = host
    self.proto = proto
    self.username = username
    self.loadPassword = loadPassword
  }
}

extension SMTPCredentials {
  // 2
  enum Proto {
    case smtps
    case starttls

    var port: UInt16 {
      switch self {
      case .smtps:
        465
      case .starttls:
        587
      }
    }
  }
}
  1. Для доступа нам нужны: сервер, к которому будем подключаться (хост), протокол подключения, имя пользователя для данного сервиса и пароль. Пароль в виде замыкания, через которое он будет доставаться из keychain, чтоб минимизировать время хранения пароля в памяти.

  2. Протокол. Выше я уже озвучивал, что фактически мы будем использовать не SMTP, а SMTPS (c TLS шифрованием). Но для использования нам также доступен SMTP + STARTTLS, и найдутся серверы, которые используют только его. Чем они отличаются? SMTPS рассчитан на то, что клиент сразу будет осуществлять TLS handshake и все дальнейшее общение производить в зашифрованном виде (прям как HTTPS). STARTTLS в свою очередь не ожидает TLS handshake сразу при подключении. То есть начало общения клиента и сервера (первое HELO/EHLO) не будут зашифрованы. Подключение закрывается TLS шифрованием только после специальной команды STARTTLS. Нам важно знать способ подключения, так как SMTPS работает по 465 порту, а STARTTLS по 587. Разница будет более понятна, когда будем реализовывать подключение. Все крупные почтовые сервисы сегодня поддерживают оба протокола.

Далее реализуем сущности для запроса к серверу и ответа от него. Начнем с запроса:

import Foundation

enum SMTPRequest {
  case helo
  case ehlo
  case starttls
  case auth
  case authUsername(String)
  case authPassword(Data)
  case mailFrom(String)
  case rcptTo(String)
  case data
  case mailMessage(String)
  case quit
}

extension SMTPRequest {
  var requestData: Data {
    get throws {
      var data: Data?

      switch self {
      case .helo:
        data = "HELO \(Constants.clientName)\r\n".data(using: .utf8)
      case .ehlo:
        data = "EHLO \(Constants.clientName)\r\n".data(using: .utf8)
      case .starttls:
        data = "STARTTLS\r\n".data(using: .utf8)
      case .auth:
        data = "AUTH LOGIN\r\n".data(using: .utf8)
      case let .authUsername(username):
        guard let base64 = username.data(using: .utf8)?.base64EncodedString() else {
          data = nil
          break
        }

        data = "\(base64)\r\n".data(using: .utf8)
      case let .authPassword(password):
        let base64 = password.base64EncodedString()
        data = "\(base64)\r\n".data(using: .utf8)
      case let .mailFrom(mail):
        data = "MAIL FROM:<\(mail)>\r\n".data(using: .utf8)
      case let .rcptTo(mail):
        data = "RCPT TO:<\(mail)>\r\n".data(using: .utf8)
      case .data:
        data = "DATA\r\n".data(using: .utf8)
      case let .mailMessage(message):
        data = message.data(using: .utf8)
      case .quit:
        data = "QUIT\r\n".data(using: .utf8)
      }

      guard let data else {
        throw Error.invalidData("Error: invalid data for command \(self)")
      }

      return data
    }
  }
}

private extension SMTPRequest {
  enum Constants {
    static let clientName = "swift-smtp.client"
  }

  enum Error: Swift.Error {
    case invalidData(String)
  }
}

Тут в целом просто в виде закрытого множества оформлены все команды, которые мы смотрели в терминале выше. Из важного: каждая команда заканчивается CRLF последовательностью (\r\n). Благодаря ей сервер понимает, что мы закончили команду. Также напоминаю, что username и пароль мы должны передавать в base64. Также отмечу, что это не все доступные команды (только необходимые нам).

Теперь сущность для ответа от сервера:

import Foundation

struct SMTPResponse {
  // 1
  let code: Code
  let message: String

  var response: String {
    "\(code.rawValue) \(message)"
  }

  // 2
  var challenge: ChallengeType? {
    guard code == .containingChallenge,
      let base64Data = Data(base64Encoded: message),
      let decodedString = String(data: base64Data, encoding: .utf8),
      let challenge = ChallengeType(rawValue: decodedString)
    else {
      return nil
    }

    return challenge
  }
}

extension SMTPResponse {
  // 3
  enum Code: Int {
    case serviceReady = 220
    case connectionClosing = 221
    case authSucceeded = 235
    case commandOK = 250
    case willForward = 251
    case containingChallenge = 334
    case startMailInput = 354
  }

  // 2
  enum ChallengeType: String {
    case username = "Username:"
    case password = "Password:"
  }
}
  1. Структура ответа, как уже разбирали выше, состоит из кода и сообщения.

  2. Если код ответа 334, то рядом с ним будет закодированное в base64 сообщение. В нашем случае это будут запросы на логин и пароль. Расширение для удобного декодирования сообщений подобного типа.

  3. Это не полный список кодов. Только то, что будет необходимо нам.

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

import Foundation

struct SMTPMail {
  let from: String
  let to: String
  let cc: [String]
  let subject: String
  let body: String

  init(
    from: String,
    to: String,
    cc: [String] = [],
    subject: String,
    body: String
  ) {
    self.from = from
    self.to = to
    self.cc = cc
    self.subject = subject
    self.body = body
  }

  var message: String {
    let cc = cc.isEmpty ? "" : "\r\nCc: \(cc.joined(separator: ","))"
    let subjectBase64 = subject.data(using: .utf8)?.base64EncodedString() ?? ""

    let date = Date()
    let formatter = DateFormatter()
    formatter.locale = Locale(identifier: "en_US_POSIX")
    formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z"
    let formattedDate = formatter.string(from: date)

    return """
    From: \(from)
    To: \(to)\(cc)
    Subject: =?utf-8?B?\(subjectBase64)?=
    Date: \(formattedDate)
    MIME-Version: 1.0
    Content-Type: text/plain; charset=utf-8
    Content-Transfer-Encoding: 8bit
    X-Mailer: SMTPSwift Client 1.0
    Message-Id: <\(UUID().uuidString)>
    
    \(body)\r\n.\r\n
    """
  }
}

Когда мы отправляли сообщение через терминал, мы ввели туда произвольное "Hello World". Но фактически у сообщения тоже есть своя структура: От кого и к кому, заголовок, дата, тип контента, id. Это необходимо чтоб другие почтовые клиенты корректно парсили письма. Произвольные отправления, по типу которых мы отправляли в терминале, могут спокойно отлетать в спам.

Все сущности реализованы, начнем писать сам клиент:

import Foundation

actor SMTPClient {
  private let credentials: SMTPCredentials
  private let connection: TCPConnection

  init(credentials: SMTPCredentials) {
    self.credentials = credentials
    self.connection = TCPConnection(
      host: credentials.host, 
      port: credentials.port
    )
  }

  func send(email: SMTPMail) throws {
        
  }
}

private extension SMTPClient {
  nonisolated enum Constants {
    static let CRLF = "\r\n"
  }

  enum Error: Swift.Error {
    case parseError(String?)
    case requestError(String)
    case serverNotReady(String)
    case authError(String)
    case sendEmailError(String)
  }
}

Интерфейс для использования будет максимально простым. Инициализируем с SMTPCredentials и дергаем единственный метод для отправки письма, передавая туда SMTPMail. Также сразу объявим константы и ошибки, которые будут использоваться в дальнейшем.

Далее будем понемногу обогащать метод отправки, начнем с подключения:

func send(email: SMTPMail) throws {
  // 1
  defer { connection.finish() }

  // 2
  try connect()
}

private func connect() throws {
  // 3
  try connection.connect()

  // 4
  if credentials.proto == .smtps {
    try connection.tlsHandshake()
  }
}
  1. По завершению метода завершаем подключение в любом случае.

  2. Выделяем логику подключения и аутентификации в отдельный метод.

  3. Инициируем подключение к хосту через метод у TCPConnection.

  4. Если мы подключаемся по SMTPS к 465 порту, то должны сразу осуществить TLS handshake, так как вся коммуникация должна быть зашифрована. Отдельное ветвление для STARTTLS будет далее.

Теперь нужно научиться читать и парсить то, что нам пришлет сервер:

private func connect() throws {
  try connection.connect()

  if credentials.proto == .smtps {
    try connection.tlsHandshake()
  }

  // 1
  let statusData = try connection.read(while: endMessageCondition)
  // 2
  let status = try parseResponse(statusData)

  // 3
  guard status.code == .serviceReady else { 
    throw Error.serverNotReady(status.response) 
  }
  print(status.response)     
}

// 2
private func parseResponse(_ data: Data) throws -> SMTPResponse {
  guard let response = String(data: data, encoding: .utf8) else {
    throw Error.parseError(nil)
  }

  guard response.count > 4 else {
    throw Error.parseError(response)
  }

  guard let code = Int(String(response.prefix(3))),
    let smtpCode = SMTPResponse.Code(rawValue: code)
  else {
    throw Error.parseError(response)
  }

  let message = String(response.suffix(response.count - 4)).replacingOccurrences(of: Constants.CRLF, with: "")
  return SMTPResponse(code: smtpCode, message: message)
}

// 1
private func endMessageCondition(data: Data) -> Bool {
  guard let string = String(data: data, encoding: .utf8),
    let lastRow = string.split(separator: Constants.CRLF).last,
    lastRow.count > 3
  else {
    return false
  }

  return lastRow[lastRow.index(lastRow.startIndex, offsetBy: 3)] == " "
}
  1. Тут мы получаем из TCPConnection полный ответ с сервера. Обязательно передаем функцию, благодаря которой TCPConnection сможет идентифицировать конец данных (так как TCP - это потоковый протокол и данные могут приходить разрозненно). Концом в нашем случае является строка, заканчивающаяся на CRLF и обязательно имеющая пробел после статус кода (после первых трех символов).

  2. Преобразуем полученные с предыдущего шага данные в наш SMTPResponse. Логика тоже достаточно простая. Первые три символа отводятся на код, все остальное - это message.

  3. Удостоверяемся, что при подключении сервер прислал нам 220 код, говорящий нам о том, что он готов к работе.

Читать мы научились, теперь нам нужен метод для отправки команды:

private func connect() throws {
  try connection.connect()

  if credentials.proto == .smtps {
    try connection.tlsHandshake()
  }

  let statusData = try connection.read(while: endMessageCondition)
  let status = try parseResponse(statusData)

  guard status.code == .serviceReady else { 
    throw Error.serverNotReady(status.response) 
  }
  print(status.response)

  // 1
  let helloResponse = try sendRequest(.ehlo)
  // 2
  guard helloResponse.code == .commandOK else { 
    throw Error.serverNotReady(helloResponse.response) 
  }    
}

// 1
@discardableResult
private func sendRequest(_ request: SMTPRequest) throws -> SMTPResponse {
  try connection.write(data: request.requestData)
  let responseData = try connection.read(while: endMessageCondition)
  return try parseResponse(responseData)
}
  1. Выделяем отдельный метод для отправки запроса. Он внутри себя отправляет команду в TCPConnection, а далее читает ответ сервера и парсит его в SMTPResponse (с помощью методов, которые мы реализовали на предыдущем шаге).

  2. Проверяем что в ответе мы получили 250 код, означающий успех (далее часто будем проверять ответ на этот код).

Если мы подключаемся к серверу через STARTTLS по 587 порту, то на текущий момент мы все еще общаемся в незашифрованном виде (кто угодно может прослушать общение с сервером), ведь tls handshake мы совершали выше только для SMTPS подключения. Настало время сделать это и для STARTTLS, так как это совершается как раз после EHLO.

private func connect() throws {
  try connection.connect()

  if credentials.proto == .smtps {
    try connection.tlsHandshake()
  }

  let statusData = try connection.read(while: endMessageCondition)
  let status = try parseResponse(statusData)

  guard status.code == .serviceReady else {
    throw Error.serverNotReady(status.response)
  }
  print(status.response)

  let helloResponse = try sendRequest(.ehlo)
  guard helloResponse.code == .commandOK else {
    throw Error.serverNotReady(helloResponse.response)
  }

  // 1
  if credentials.proto == .starttls {
    let starttlsResponse = try sendRequest(.starttls)
    guard starttlsResponse.code == .serviceReady else {
      throw Error.serverNotReady(starttlsResponse.response)
    }

    try connection.tlsHandshake()

    let hello2Response = try sendRequest(.ehlo)
    guard hello2Response.code == .commandOK else {
      throw Error.serverNotReady(hello2Response.response)
    }
  }
}
  1. Только для 587 порта отправляем команду STARTTLS, после этого делаем tls handshake, и после еще раз отправляем EHLO (уже в зашифрованном виде) и удостоверяемся в успешном ответе от сервера. Зашифрованное соединение установлено.

На этом вся разница между SMTPS и STARTTLS. Далее флоу будет идентичный. Осуществляем авторизацию (по зашифрованному каналу для обоих протоколов).

private func connect() throws {
  // ...

  // 1
  let usernameResponse = try sendRequest(.auth)
  guard usernameResponse.challenge == .username else {
    throw Error.authError(usernameResponse.response)
  }
  let passwordResponse = try sendRequest(.authUsername(credentials.username))

  // 2
  guard passwordResponse.challenge == .password else {
    throw Error.authError(passwordResponse.response)
  }
  let passwordData = try credentials.loadPassword()
  let authResponse = try sendRequest(.authPassword(passwordData))

  // 3
  guard authResponse.code == .authSucceeded else {
    throw Error.authError(authResponse.response)
  }
  print(authResponse.response)
}
  1. Отправляем команду AUTH LOGIN, удостоверяемся что сервер после этого запросил у нас username и отправляем ему его в ответ.

  2. После отправленного username сервер должен запросить пароль. Проверяем это и отправляем пароль.

  3. Удостоверяемся что сервер подтвердил аутентификацию.

На этом наш метод connect реализован. Мы соединились с сервером, установили зашифрованное подключение и прошли аутентификацию. Нам осталось только отправить сообщение:

func send(email: SMTPMail) throws {
  defer { connection.finish() }

  try connect()

  // 1
  let fromResponse = try sendRequest(.mailFrom(email.from))
  guard fromResponse.code == .commandOK else {
    throw Error.sendEmailError(fromResponse.response)
  }
  print(fromResponse.response)

  // 2
  for mail in [email.to] + email.cc {
    do {
      let toResponse = try sendRequest(.rcptTo(mail))
      print(toResponse.response)
    } catch {
      print("Failed setting \(mail) as recipient: \(error)")
    }
  }

  // 3
  let dataResponse = try sendRequest(.data)
  guard dataResponse.code == .startMailInput else {
    throw Error.sendEmailError(dataResponse.response)
  }
  print(dataResponse.response)

  // 4
  let dataEndResponse = try sendRequest(.mailMessage(email.message))
  guard dataEndResponse.code == .commandOK else {
    throw Error.sendEmailError(dataEndResponse.response)
  }
  print(dataEndResponse.response)

  // 5
  try sendRequest(.quit)
}
  1. Отправляем MAIL FROM.

  2. Отдельно отправляем RCPT TO для каждого получателя.

  3. Инициируем старт ввода сообщения.

  4. Отправляем само сообщение.

  5. Завершаем сессию, сообщение успешно отправлено.

На этом клиент для отправки SMTP сообщений готов. Полный код класса. Отмечу, что реализация неполноценная. Нет поддержки всех способов аутентификации, нет поддержки всех SMTP расширений (например PIPELINING), нет возможности отправить вложение. Реализовали заготовку для MVP. Давайте теперь посмотрим как это все выглядит.

Результат

Код всего остального вокруг SMTP разбирать не буду. Кому интересно, можете ознакомиться тут. В этом блоке лишь покажу, что получилось в итоге.

Начинаем с экрана ввода кредов. Запрашиваем у пользователя ввести сервер, username и пароль для него. Пароль сохраняем в keychain, все остальное кладем в defaults (при перезаходе в приложение повторно не запрашиваем данные).

Современные почтовые клиенты не запрашивают ввод почтового сервера, так как они сами пытаются его вычислить по домену из почты, используя Autodiscover (и другие способы). В рамках данного приложения не стал это реализовывать, оставил только ручной ввод сервера.

smtp.mail.ru - для mail
smtp.yandex.ru - для яндекса
smtp.gmail.com - для gmail

Второй экран - отправка письма. Ряд филдов для ввода данных и отправка (через наш сервис)

Это все приложение. На практическом примере познакомились с SMTP протоколом и (с нуля) написали клиент для отправки почтовых сообщений. Клиент еще можно много чем расширить. Реализовать автоопределение сервера, подключить еще одну важную составляющую - получение писем с помощью IMAP, или добавить отправку вложений и поддержку SMTP расширений. Но это уже в следующий раз..

Полезные ресурсы