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

GoEmailVerifier — практикум

Разработчику Начальный уровень

См. также: Первая программа · TCP и UDP · Тестирование · CLI на cobra · GoHTMLParser.


О практикуме

GoEmailVerifier — консольная утилита, которая отвечает на вопрос "можно ли доставить письмо на этот адрес?" без отправки самого письма. Проверка идёт в три слоя:

  1. Синтаксис — строка похожа на email (user@domain.tld).
  2. DNS — у домена есть MX-записи, указывающие на почтовые серверы.
  3. SMTP — один из MX-серверов принимает или отклоняет адрес через команду RCPT TO.

Проект целиком на стандартной библиотеке Goflag, net, net/smtp, regexp, testing. Сторонних зависимостей нет — весь код собирается по шагам на этапах 0–6 ниже.

Соседний GoHTMLParser — другой сетевой сценарий (HTTP GET и парсинг HTML), но та же идея "маленькая CLI-утилита на stdlib".

Для кого материал

Нужны Go 1.22+, пройденная первая программа и базовое понимание обработки ошибок. Сеть — TCP и UDP; модули — go mod; тесты — статья про testing.


Ключевые термины

ТерминПростыми словами
EmailАдрес вида локальная_часть@домен; локальная часть — имя ящика, домен — зона, где ищут почтовые серверы
DNSСистема имён: по домену gmail.com возвращает записи разных типов (A, MX, TXT…)
MX-записьMail eXchange — "куда слать почту для этого домена"; у записи есть приоритет (меньше число — важнее сервер)
SMTPПротокол передачи почты между серверами; работает поверх TCP, обычно порт 25
RCPT TOSMTP-команда "кому письмо"; сервер отвечает OK или кодом ошибки (например 550)
Null-senderПустой отправитель в MAIL FROM <> — часто используют при проверке без реальной отправки
CLICommand Line Interface — программа, которой передают аргументы и флаги в терминале

Теория протоколов на уровне интеграций — HTTP как основа веб-интеграций; здесь мы опускаемся на уровень TCP и прикладного SMTP.


Как устроена доставка почты

Когда вы отправляете письмо с you@yourmail.com на friend@gmail.com, происходит примерно следующее:

  • DNS не знает, существует ли конкретный ящик — только где искать почтовый сервер домена.
  • SMTP может сказать "ящик есть" или "нет", но многие серверы намеренно не раскрывают это незнакомым IP (anti-harvesting).
  • Утилита учебная: она показывает работу net.LookupMX, net/smtp и обработку ошибок в Go, а не продуктовую верификацию базы контактов.
Ограничения SMTP-проверки

Многие почтовые серверы не отвечают на RCPT TO с чужих IP или всегда возвращают "OK". Порт 25 часто закрыт у домашних провайдеров. Результат "вероятно существует" — не юридическое доказательство доставляемости.


Чему научитесь

После практикума вы сможете:

  • инициализировать Go-модуль и собрать один бинарник через go build;
  • разложить код по файлам одного пакета main без преждевременного усложнения;
  • вызывать DNS из Go и интерпретировать MX-записи;
  • установить TCP-соединение с таймаутом и пройти минимальный SMTP-диалог;
  • описать результат проверки структурой с полем-указателем (*bool для трёх состояний);
  • принять аргументы через пакет flag и вернуть ненулевой exit code;
  • написать table-driven тест для чистых функций.

Архитектура проекта

ФайлРоль
main.goCLI — флаги -timeout, -mx, вывод результата, код выхода
verifier.goСтруктура Result, тип Verifier, метод Verify, ParseDomain, PrintMX
dns.golookupMX — DNS-запрос и сортировка MX по приоритету
smtp.goverifySMTP — TCP, EHLO, MAIL FROM, RCPT TO
verifier_test.goTable-driven тесты regex и ParseDomain

Почему не один файл? Каждый слой (CLI, оркестрация, DNS, SMTP) меняется по своим причинам — так проще читать и тестировать. Пока всё в package main; выделение в internal/ имеет смысл, когда появятся переиспользуемые пакеты — см. GoHTMLParser.

Для одной команды и двух флагов достаточно flag из stdlib. Дерево подкоманд, конфиг из YAML и shell completion — CLI на cobra и viper.


Как проходить практикум

  1. Создайте каталог и выполните go mod init (этап 0).
  2. Идите по этапам 0–6 ниже — после каждого шага запускайте go run . или go test.
  3. На этапе 2 проверьте -mx google.com — в терминале должен появиться список MX с приоритетами.
  4. На этапе 5 соберите бинарник go build -o email-verifier . и вызовите user@gmail.com.
  5. Отметьте пункты "Самопроверка" в конце каждого этапа.
  6. Если SMTP падает по timeout — это нормально для домашней сети; DNS-часть (-mx) всё равно должна работать.

Карта этапов

ЭтапФокусРезультат
0Модуль GoКаталог с go.mod, первый запуск
1Модель данныхResult, regex, NewVerifier
2DNSlookupMX в dns.go, команда PrintMX
3SMTPverifySMTP в smtp.go
4VerifierПолный Verify, ParseDomain, вывод
5CLImain.go с flag и exit code
6Тестыverifier_test.go, go test

Этап 0 — модуль и каркас

Цель — рабочий Go-модуль с одним файлом и командой go run.

Теория

Go-модуль — каталог с файлом go.mod, в котором указаны имя модуля и версия Go. С Go 1.16+ модули — стандартный способ организации проекта; старый $GOPATH для новых учебных проектов не нужен. Подробнее — Модули и go mod, Первая программа.

Пакет main — единственный, из которого собирается исполняемый файл. Функция func main() — точка входа, аналог public static void main в Java, но без класса-обёртки — см. package main и func main().

Команды

mkdir email-verifier && cd email-verifier
go mod init github.com/you/email-verifier
  • mkdir создаёт каталог проекта.
  • go mod init записывает в go.mod путь модуля (здесь вымышленный github.com/you/... — для локальной учебной работы подойдёт любой уникальный путь).

Создайте main.go:

package main

import "fmt"

func main() {
fmt.Println("GoEmailVerifier — заготовка")
}

Запуск:

go run .

Точка . означает "текущий модуль" — Go найдёт все .go файлы пакета main и скомпилирует их.

Разбор main.go

СтрокаСмысл
package mainИсполняемый пакет; имя main — требование toolchain
import "fmt"Подключение пакета форматированного вывода из stdlib
func main()Начало выполнения программы

Самопроверка

  • go version показывает Go 1.22+.
  • В каталоге есть go.mod и main.go.
  • go run . печатает заготовку без ошибок.

Этап 1 — Result и проверка формата

Цель — описать результат проверки структурой и отсечь заведомо неверный синтаксис email.

Теория

Валидация email в продакшене обычно двухуровневая. Общая таблица проверок — Проверка и валидация.

  • Синтаксис — строка соответствует шаблону (быстро, офлайн).
  • Доставляемость — домен принимает почту, ящик может существовать (медленно, нужна сеть).

На этом этапе делаем только синтаксис. Полный RFC 5322 разбирать не будем — для учебного проекта достаточно regexp, который отсекает явный мусор (@, пробелы, отсутствие домена). Работа со строками — Строки, руны и Unicode.

Поле Exists *bool — указатель на bool, а не сам bool. Так в Go кодируют три состояния:

Значение ExistsИнтерпретация
nilSMTP ещё не вызывали или не удалось получить ответ
&trueСервер принял RCPT TO
&falseСервер явно отклонил адрес

Обычный bool не различает "false" и "не проверяли" — оба дают false.

Код

Создайте verifier.go:

Код ITЗагрузка примера кода…

Временно измените main.go:

package main

import (

"fmt"
"time"
)

func main() {
v := NewVerifier(15 * time.Second)
fmt.Printf("%+v\n", v.Verify("User@Example.COM"))
fmt.Printf("%+v\n", v.Verify("not-an-email"))
}

Разбор Verify на этапе 1

ФрагментЗачем
regexp.MustCompileКомпиляция шаблона один раз при старте; при ошибке в паттерне — panic (допустимо для константы)
TrimSpace + ToLowerНормализация ввода: User@Mail.COMuser@mail.com
SplitN(..., "@", 2)Ровно одно разбиение — локальная часть может содержать редкие символы, домен — parts[1]
return result без ErrorНекорректный формат — не exception, а часть доменной модели (ошибки в Go)

Самопроверка

  • User@Example.COMValid: true, домен example.com, регистр нормализован.
  • not-an-emailValid: false, Detail про формат.
  • Exists пока всегда nil.

Этап 2 — DNS MX lookup

Цель — по домену получить список почтовых серверов и вывести их с приоритетами.

Теория

DNS (Domain Name System) переводит имена в данные. Для почты нужен тип записи MX (Mail eXchange). У каждой MX-записи два поля:

  • Host — hostname сервера (часто с точкой в конце, например gmail-smtp-in.l.google.com.).
  • Pref (preference) — приоритет; сервер с меньшим числом пробуют первым.

Если MX нет — домен, скорее всего, не принимает почту по SMTP (или использует A-запись как fallback, что мы здесь не разбираем).

В Go DNS вызывают через пакет net: net.LookupMX(domain) — блокирующий вызов с резолвером ОС. Это тот же уровень стека, что и TCP-сокеты — TCP и UDP в Go.

Код

Создайте dns.go:

Код ITЗагрузка примера кода…

Добавьте в verifier.go функцию PrintMX и импорт "fmt":

// PrintMX выводит MX-записи домена (для демонстрации работы DNS).
func PrintMX(domain string) error {
records, err := lookupMX(domain)
if err != nil {
return err
}
fmt.Printf("MX-записи для %s:\n", domain)
for _, mx := range records {
fmt.Printf(" приоритет %d → %s\n", mx.Pref, strings.TrimSuffix(mx.Host, "."))
}
return nil
}

Проверка из main.go:

func main() {
if err := PrintMX("google.com"); err != nil {
fmt.Println("ошибка:", err)
}
}

Разбор lookupMX

СтрокаСмысл
net.LookupMXЗапрос к DNS; при NXDOMAIN или сетевой ошибке — err != nil
fmt.Errorf("...: %w", err)Обёртка ошибки — цепочка причин, идиома Go (Обработка ошибок в Go)
len(mxRecords) == 0Технически редкий случай, но явная проверка лучше пустого цикла
sort.Slice + PrefСортировка in-place; первый элемент — самый приоритетный MX
TrimSuffix(mx.Host, ".")DNS часто возвращает FQDN с trailing dot — для вывода и SMTP убираем

Пример вывода

Для google.com вы увидите несколько строк с разными приоритетами — это нормально: у крупных доменов несколько MX для отказоустойчивости.

Самопроверка

  • PrintMX("google.com") — список MX с приоритетами.
  • PrintMX("домен-которого-нет.invalid") — понятная ошибка DNS.
  • Функция возвращает error, а не паникует.

Этап 3 — SMTP-проверка

Цель — установить TCP-соединение с MX-сервером и узнать, принимает ли он адрес получателя.

Теория

SMTP (Simple Mail Transfer Protocol) — текстовый протокол поверх TCP. Минимальный диалог для проверки ящика (без отправки тела письма):

  1. TCP connect на порт 25 MX-хоста.
  2. EHLO / HELO — представиться клиентом.
  3. MAIL FROM — указать отправителя (мы используем пустой <> — null-sender).
  4. RCPT TO — указать получателя; здесь сервер говорит, есть ящик или нет.
  5. QUIT — завершить сессию. Команду DATA не вызываем — письмо не отправляем.

Типичные коды ответа:

КодЧастый смысл
250Команда принята
550Mailbox unavailable — ящик не найден или недоступен
551User not local
553Mailbox name not allowed

Пакет net/smtp в stdlib инкапсулирует отправку команд; низкоуровневый сокет — net.DialTimeout из статьи про TCP.

Код

Создайте smtp.go:

Код ITЗагрузка примера кода…

Разбор verifySMTP

ШагКодЗачем
АдресJoinHostPort(host, "25")Корректная склейка host:port с учётом IPv6
Таймаут connectDialTimeoutНе зависнуть, если MX не отвечает
Дедлайн сессииSetDeadlineОграничить весь обмен, не только connect
Освобождениеdefer conn.Close()Закрыть сокет при любом выходе из функции
ПриветствиеHello("email-verifier.local")Имя клиента для логов сервера
Ошибка RCPTreturn false, parse..., nilОтвет "ящик не найден" — не transport error, а бизнес-результат
Успехreturn true, "...", nilСервер принял получателя
Порт 25

Если на этапе 4 появляется connection timed out — проверьте, не блокирует ли провайдер исходящий порт 25. Флаг -mx (этап 5) работает без SMTP. Для экспериментов подойдёт VPS или корпоративная сеть с открытым 25.

Самопроверка

  • go build . — компиляция без ошибок.
  • Понимаете разницу между err != nil (сеть, протокол) и exists == false (SMTP-код 550).

Этап 4 — оркестрация Verify

Цель — связать синтаксис, DNS и SMTP в один метод и красиво вывести результат.

Теория

Оркестрация — функция верхнего уровня задаёт порядок шагов и правила fallback:

  • синтаксис неверен → дальше не идём;
  • DNS не ответил → фиксируем Error, SMTP не вызываем;
  • первый MX недоступен → пробуем следующий по приоритету;
  • один MX ответил на RCPT → возвращаем результат и не опрашиваем остальных.

ParseDomain понадобится CLI-флагу -mx: пользователь может передать и google.com, и user@gmail.com — утилита извлечёт домен.

Код Verify

Замените метод Verify в verifier.go:

Код ITЗагрузка примера кода…

Код ParseDomain

Код ITЗагрузка примера кода…

Добавьте "net" в import verifier.go.

Вывод результата

В main.go (временно, до этапа 5):

Код ITЗагрузка примера кода…

Разбор цикла по MX

СитуацияПоведение
verifySMTP вернул err != nilЗапоминаем в lastErr, continue — пробуем следующий MX
verifySMTP вернул exists == falseЭто ответ сервера, не сбой сети — сразу return result
Все MX недоступныExists остаётся nil, в Detail текст про недоступность
switch в printResultТри ветки для *bool — без dereference при nil

Слово "вероятно" в выводе — намеренное: SMTP-проверка эвристическая, не доказательство.

Самопроверка

  • Валидный email — DNS находит MX; SMTP отвечает или timeout (зависит от сети).
  • @example.com без локальной части — отсекается regex на этапе 1.
  • printResult не паникует при Exists == nil.

Этап 5 — CLI с flag

Цель — принять аргументы из командной строки, добавить флаги и код выхода для скриптов.

Теория

CLI-утилита в Unix-смысле — программа с:

  • positional arguments — то, что остаётся после флагов (email-verifier user@gmail.com);
  • flags — опции (-mx, -timeout 30s);
  • exit code — 0 при успехе, ненулевой при ошибке (важно для shell и CI).

Пакет flag из stdlib достаточен для одной команды — так же устроены многие примеры в Простых приложениях. Когда появятся подкоманды (tool check, tool mx) — cobra и viper.

flag.Duration понимает суффиксы Go — 30s, 1m30s, 500ms.

Код

Замените main.go финальной версией:

Код ITЗагрузка примера кода…

Сборка и запуск

go build -o email-verifier .
./email-verifier -mx google.com
./email-verifier user@gmail.com
echo $? # код выхода: 0 или 1

go build создаёт статически слинкованный бинарник — на машине без установленного Go его можно запускать напрямую (Первая программа).

Разбор CLI

ЭлементНазначение
flag.Parse()Разобрать os.Args; после него — только positional args
flag.Args()Слайс аргументов без флагов
fmt.Fprintf(os.Stderr, ...)Сообщения об ошибках в stderr — stdout свободен для пайпов
os.Exit(1)Сигнал ошибки вызывающему скрипту
Условие перед ExitНевалидный формат, transport error или Exists == false — всё это "неуспех"

Самопроверка

  • Без аргументов — usage в stderr и exit 1.
  • -mx google.com — список MX, exit 0.
  • -timeout 5s — более короткое ожидание SMTP.
  • go build — один файл email-verifier (или .exe на Windows).

Этап 6 — table-driven тесты

Цель — автоматически проверить чистые функции без сети.

Теория

Unit-тест в Go — файл *_test.go, функции TestXxx(t *testing.T), запуск go test. Table-driven стиль — таблица кейсов в слайсе структур и цикл с t.Run — идиома языка (Тестирование в Go).

Что тестируем:

  • regex — детерминирован, не ходит в сеть;
  • ParseDomain — чистая функция строк → строка/error.

Что не тестируем в unit-тестах без mock:

  • lookupMX — реальный DNS;
  • verifySMTP — живой MX на порту 25.

Для интеграционных тестов нужен стаб SMTP или build tag integration — за рамками базового практикума.

Код

Создайте verifier_test.go:

Код ITЗагрузка примера кода…

Запуск

go test -v .
go test -cover .

Разбор тестов

ПриёмГде
package mainТесты видят неэкспортируемые emailRegex, ParseDomain
Два слайса valid/invalidПростая таблица без лишней структуры для regex
tests := []struct{...}Классический table-driven для ParseDomain
(err != nil) != tt.wantErrПроверка "ожидали ли ошибку" одним сравнением
t.Errorf + %q%q печатает строки в кавычках — видны пробелы

Самопроверка

  • go test — все тесты зелёные.
  • В каталоге пять файлов — main.go, verifier.go, dns.go, smtp.go, verifier_test.go.
  • -cover показывает покрытие (SMTP-файлы будут с низким % — это ожидаемо).

Что дальше

НаправлениеМатериал
Подкоманды и YAML-конфигCLI на cobra и viper
HTTP API вместо CLIGin, Веб на stdlib
Параллельная проверка списка адресовГорутины и каналы
Покрытие, mock SMTPТестирование
Парсинг HTML по URLGoHTMLParser

Идеи для самостоятельного расширения:

  • читать адреса из файла (по одному на строку);
  • флаг -json для машиночитаемого вывода;
  • worker pool из N горутин для пакетной проверки;
  • логирование через slog вместо fmt.Printf.

Частые ошибки

СимптомВероятная причинаРешение
connection timed out на SMTPПорт 25 закрыт провайдером-mx для DNS; другая сеть или VPS
MX-записи не найденыОпечатка в домене или домен без почтыПроверьте домен через nslookup -type=mx
undefined: lookupMXdns.go не в том пакетеВсе файлы — package main, один каталог
Тесты не видят функцииДругой package в *_test.gopackage main, не main_test
flag provided but not definedАргументы до flag.ParseСначала объявить флаги, потом Parse()
Паника при *r.ExistsDereference при nilswitch с веткой r.Exists == nil