Пошаговое руководство по внедрению и развертыванию собственного одноразового сервера электронной почты с нуля.
Как пользователь 10-минутной электронной почты, я всегда задавался вопросом, насколько трудно будет внедрить и настроить свой собственный одноразовый сервер электронной почты - тот, который используется исключительно для размещения вашего адреса электронной почты на одном из сторонних серверов. Как выясняется, внедрение такого сервера - удивительно гладкий и отрадный опыт!
Результатом этого небольшого личного хакатона является электронная почта, сервер одноразовой электронной почты с открытым исходным кодом. В этом сообщении вы увидите, как написать и развернуть его самостоятельно. Здесь размещен открытый экземпляр электронной почты.
Сведения о SMTP
Протокол SMTP устарел. Я также был удивлен, обнаружив, что для такого проверенного боем и надежного протокола не так много ресурсов, чтобы узнать о его деталях, особенно если вы хотели бы реализовать его самостоятельно. Фактически, все учебные пособия по «созданию собственного SMTP» в основном начинаются с «установки Postfix» - полноценного SMTP-сервера производственного уровня! Давайте начнем с основ.
SMTP - это протокол прикладного уровня, который выполняется поверх транспортного уровня, который обычно является TCP. SMTP основан на тексте и ориентирован на подключение, что, к счастью, делает его читаемым человеком. SMTP-транзакция - это просто последовательность сообщений «запрос-ответ».
Пример разговора между клиентом и сервером может выглядеть следующим образом:
Server->Client: 220 edgemail server reporting for duty 🫡
Client->Server: HELO smtp.example.com
Server->Client: 250-smtp.example.com Hello user
Client->Server: MAIL FROM:<robert@example.com>
Server->Client: 250 Ok
Client->Server: RCPT TO:<rosie@example.com>
Server->Client: 250 Ok
Client->Server: DATA
Server->Client: 354 End data with <CR><LF>.<CR><LF>
Client->Server: From: "Robert <robert@example.com>
Client->Server: To: "Rosie <rosie@example.com>
Client->Server: Date: Wed, 19 Apr 2023, 12:30:34
Client->Server: Subject: Hi Rosie!
Client->Server: Hi Rosie! How are you today?
Client->Server: <CR><LF>.<CR><LF>
Server->Client: 250 Ok
Client->Server: QUIT
Server->Client: 221 Bye
Этого простого примера достаточно, чтобы начать рисование реализации сервера, который был бы довольно простым конечным автоматом, способным обрабатывать несколько команд: приветствие пользователя и получение почты. Отправка почты является более сложным, и не будет освещаться в этой статье. К счастью, нам не нужно когда-либо отправлять почту с нашего одноразового сервера - он будет использоваться только в качестве разового адреса, используемого для регистрации нежелательных бюллетеней.
Внедрение
Название проекта - edgemail, что является данью тому факту, что он обеспечивает низкую задержку для своих пользователей благодаря использованию базы данных edge-native. Подробнее в нескольких абзацах!
Сервер будет реализован в виде 3 отдельных уровней:
- Конечный автомат SMTP, ответственный за обработку связи SMTP
- База данных для хранения почты
- Клиент для просмотра почты
Конечный автомат SMTP
SMTP является довольно сложным стандартом, но, к счастью, в целях одноразовой электронной почты мы можем игнорировать все расширения, аутентификацию, авторизацию и т.д., сосредоточившись на возможности просто получать почту. На (немного упрощенном) рисунке ниже показано, как будет работать наш сервер.
После установления соединения сервер представляет себя, а затем выполняет квитирование, принимая сообщение HELO или EHLO (extended-hello). После подтверждения связи сервер ожидает выполнения команды MAIL, которая содержит информацию о том, кто отправил сообщение электронной почты, а затем команды RCPT с информацией обо всех получателях. Как только отправитель и получатели известны, сервер принимает команду DATA, которая позволяет ему получать текст сообщения электронной почты. После этого выполняется процедура прощания и транзакция.
use crate::database;
use anyhow::{Context, Result};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::Mutex;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Mail {
pub from: String,
pub to: Vec<String>,
pub data: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum State {
Fresh,
Greeted,
ReceivingRcpt(Mail),
ReceivingData(Mail),
Received(Mail),
}
struct StateMachine {
state: State,
ehlo_greeting: String,
}
/// Конечный автомат, способный обрабатывать команды SMTP
/// для получения почты..
/// Используйте handle_smtp () для обработки одной команды.
/// Возвращаемое значение от handle_smtp() является ответом
/// это должно быть отправлено обратно клиенту.
impl StateMachine {
const OH_HAI: &[u8] = b"220 edgemail\n";
const KK: &[u8] = b"250 Ok\n";
const AUTH_OK: &[u8] = b"235 Ok\n";
const SEND_DATA_PLZ: &[u8] = b"354 End data with <CR><LF>.<CR><LF>\n";
const KTHXBYE: &[u8] = b"221 Bye\n";
const HOLD_YOUR_HORSES: &[u8] = &[];
pub fn new(domain: impl AsRef<str>) -> Self {
let domain = domain.as_ref();
let ehlo_greeting = format!("250-{domain} Hello {domain}\n250 AUTH PLAIN LOGIN\n");
Self {
state: State::Fresh,
ehlo_greeting,
}
}
/// Обрабатывает одну команду SMTP и возвращает правильный отклик SMTP
pub fn handle_smtp(&mut self, raw_msg: &str) -> Result<&[u8]> {
tracing::trace!("Received {raw_msg} in state {:?}", self.state);
let mut msg = raw_msg.split_whitespace();
let command = msg.next().context("received empty command")?.to_lowercase();
let state = std::mem::replace(&mut self.state, State::Fresh);
match (command.as_str(), state) {
("ehlo", State::Fresh) => {
tracing::trace!("Sending AUTH info");
self.state = State::Greeted;
Ok(self.ehlo_greeting.as_bytes())
}
("helo", State::Fresh) => {
self.state = State::Greeted;
Ok(StateMachine::KK)
}
("noop", _) | ("help", _) | ("info", _) | ("vrfy", _) | ("expn", _) => {
tracing::trace!("Got {command}");
Ok(StateMachine::KK)
}
("rset", _) => {
self.state = State::Fresh;
Ok(StateMachine::KK)
}
("auth", _) => {
tracing::trace!("Acknowledging AUTH");
Ok(StateMachine::AUTH_OK)
}
("mail", State::Greeted) => {
tracing::trace!("Receiving MAIL");
let from = msg.next().context("received empty MAIL")?;
let from = from
.strip_prefix("FROM:")
.context("received incorrect MAIL")?;
tracing::debug!("FROM: {from}");
self.state = State::ReceivingRcpt(Mail {
from: from.to_string(),
..Default::default()
});
Ok(StateMachine::KK)
}
("rcpt", State::ReceivingRcpt(mut mail)) => {
tracing::trace!("Receiving rcpt");
let to = msg.next().context("received empty RCPT")?;
let to = to.strip_prefix("TO:").context("received incorrect RCPT")?;
tracing::debug!("TO: {to}");
mail.to.push(to.to_string());
self.state = State::ReceivingRcpt(mail);
Ok(StateMachine::KK)
}
("data", State::ReceivingRcpt(mail)) => {
tracing::trace!("Receiving data");
self.state = State::ReceivingData(mail);
Ok(StateMachine::SEND_DATA_PLZ)
}
("quit", State::ReceivingData(mail)) => {
tracing::trace!(
"Received data: FROM: {} TO:{} DATA:{}",
mail.from,
mail.to.join(", "),
mail.data
);
self.state = State::Received(mail);
Ok(StateMachine::KTHXBYE)
}
("quit", _) => {
tracing::warn!("Received quit before getting any data");
Ok(StateMachine::KTHXBYE)
}
(_, State::ReceivingData(mut mail)) => {
tracing::trace!("Receiving data");
let resp = if raw_msg.ends_with("\r\n.\r\n") {
StateMachine::KK
} else {
StateMachine::HOLD_YOUR_HORSES
};
mail.data += raw_msg;
self.state = State::ReceivingData(mail);
Ok(resp)
}
_ => anyhow::bail!(
"Unexpected message received in state {:?}: {raw_msg}",
self.state
),
}
}
}
TCP-сервер
Как только у нас будет конечный автомат SMTP, пришло время подключить его к серверу TCP. Работа сервера будет чрезвычайно простой:
- Принять новое подключение
- Отправить приветствие SMTP
- Получение команды SMTP
- Обработка команды с помощью конечного автомата SMTP
- Возврат ответа пользователю
- Если почта была получена, сохранить ее в базе данных
С крейтом Tokio реализация такого TCP-сервера будет ветерком.
Поскольку наш сервер предназначен для использования в качестве временного почтового ящика для одного человека, мы не будем беспокоиться об управлении пользователями. Вместо этого сервер будет принимать все сообщения и сохранять их в базе данных. Чтобы не переполнить хранилище, старая почта будет периодически очищаться.
/// SMTP-сервер, который обрабатывает подключения пользователей
/// и реплицирует полученные сообщения в базу данных.
pub struct Server {
stream: tokio::net::TcpStream,
state_machine: StateMachine,
db: Arc<Mutex<database::Client>>,
}
impl Server {
/// Создание нового сервера из подключенного потока
pub async fn new(domain: impl AsRef<str>, stream: tokio::net::TcpStream) -> Result<Self> {
Ok(Self {
stream,
state_machine: StateMachine::new(domain),
db: Arc::new(Mutex::new(database::Client::new().await?)),
})
}
/// Запуск цикла сервера, прием и обработка команд SMTP
pub async fn serve(mut self) -> Result<()> {
self.greet().await?;
let mut buf = vec![0; 65536];
loop {
let n = self.stream.read(&mut buf).await?;
if n == 0 {
tracing::info!("Received EOF");
self.state_machine.handle_smtp("quit").ok();
break;
}
let msg = std::str::from_utf8(&buf[0..n])?;
let response = self.state_machine.handle_smtp(msg)?;
if response != StateMachine::HOLD_YOUR_HORSES {
self.stream.write_all(response).await?;
} else {
tracing::debug!("Not responding, awaiting more data");
}
if response == StateMachine::KTHXBYE {
break;
}
}
match self.state_machine.state {
State::Received(mail) => {
self.db.lock().await.replicate(mail).await?;
}
State::ReceivingData(mail) => {
tracing::info!("Received EOF before receiving QUIT");
self.db.lock().await.replicate(mail).await?;
}
_ => {}
}
Ok(())
}
/// Отправляет начальное приветствие SMTP
async fn greet(&mut self) -> Result<()> {
self.stream
.write_all(StateMachine::OH_HAI)
.await
.map_err(|e| e.into())
}
}
База данных
Для хранения всех почтовых отправлений мы будем использовать Turso - базу данных SQL Turso может получать запросы через HTTP, что позволяет выполнять запросы прямо из браузера, и это делает ответы до смешного быстрыми.
Кроме того, клиент Rust для Turso, libsql-client, также способен хранить всё в базе данных libSQL, которая хранится в локальном файле, так же как и SQLite. И это просто идеально подходит для тестов и быстрого создания прототипов.
Чтобы создать новую базу данных, начните с установки инструмента командной строки turso.
Как только вы закончите, создайте новую базу данных - назовем ее edgemaildb - с помощью:
turso db create edgemaildb
Схема хранения почты будет простой, так как вся почта хранится в одной таблице. Эта таблица будет автоматически создана при запуске сервера электронной почты, если она не существует:
CREATE TABLE IF NOT EXISTS mail (date text, sender text, recipients text, data text)
Для ускорения запросов к почтовой таблице можно создать следующие индексы. Они также будут автоматически созданы при запуске электронной почты, если они отсутствуют:
CREATE INDEX IF NOT EXISTS mail_date ON mail (date)
CREATE INDEX IF NOT EXISTS mail_recipients ON mail (recipients)
Подключение к базе данных из Rust
Turso совместим с драйвером libSQL Rust. Для работы драйвера требуется только две части информации:
- URL-адрес базы данных.
- Маркер аутентификации, который требуется только при подключении к удаленному экземпляру Turso.
Они могут быть определены как переменные среды. К экземпляру Turso подключится следующая конфигурация:
LIBSQL_CLIENT_URL=https://your-db-name-and-username.turso.io
LIBSQL_CLIENT_TOKEN=your-auth-token
… но вы также можете указать локальный файл для использования в целях разработки - не нужно ни на что подписываться!
LIBSQL_CLIENT_URL=file:///tmp/edgemail-test.db
В электронной почте ситуация еще проще. Если URL-адрес не указан, сервер автоматически запустится с локальной базы данных, хранящейся в файле edgemail.db
, размещенном во временном каталоге системы:
pub async fn new() -> Result<Self> {
if std::env::var("LIBSQL_CLIENT_URL").is_err() {
let mut db_path = std::env::temp_dir();
db_path.push("edgemail.db");
let db_path = db_path.display();
tracing::warn!("LIBSQL_CLIENT_URL not set, using a default local database: {db_path}");
std::env::set_var("LIBSQL_CLIENT_URL", format!("file://{db_path}"));
}
let db = libsql_client::new_client().await?;
db.batch([
"CREATE TABLE IF NOT EXISTS mail (date text, sender text, recipients text, data text)",
"CREATE INDEX IF NOT EXISTS mail_date ON mail(date)",
"CREATE INDEX IF NOT EXISTS mail_recipients ON mail(recipients)",
])
.await?;
Ok(Self { db })
}
После перехода к производству и хранения сообщений электронной почты на edge достаточно указать переменную LIBSQL_CLIENT_URL
на URL-адрес базы данных Turso и LIBSQL_CLIENT_TOKEN
на маркер аутентификации.
Можно проверить URL-адрес базы данных, выполнив следующую команду:
turso db show --url edgemaildb
Чтобы создать маркер проверки подлинности, выполните следующую команду:
turso db tokens create edgemaildb
Клиент
Так как Turso работает по HTTP, наш клиент будет статической веб-страницой, которая может быть размещена бесплатно в множестве мест, как GitHub Pages. Если вас беспокоит утечка маркеров доступа к базе данных после публикации на веб-странице - не бойтесь. С помощью Turso можно создавать маркеры только для чтения базы данных:
turso db tokens create edgemaildb --read-only
Они предоставляют доступ только для чтения к базе данных. В этом конкретном случае хорошо «прокинуть» токены в браузеры пользователей, потому что вся база данных, по замыслу, читаема всеми. Отправка запросов базы данных прямо из браузера также отлично подходит для вашего ожидания - ваш браузер собирается получить данные непосредственно из базы данных, без участия посредников.
Клиент будет разделен на две страницы: главную страницу для выбора имени пользователя и папку «Входящие».
Главная страница представляет собой простую форму, которая позволяет пользователям выбрать любое имя пользователя, которое они хотели бы использовать. SMTP-сервер в любом случае принимает всю входящую почту, поэтому можно выбрать любое имя пользователя. Для не определившихся простой псевдослучайный генератор имен пользователей предложит подсказку.
После выбора имени пользователя пользователи переходят на страницу «Входящие», где происходит вся магия.
Страница «Входящие» извлекает всю почту, адресованную данному пользователю, непосредственно из базы данных, по существу, отправляя следующий SQL-запрос:
const req = new XMLHttpRequest();
const url = "https://edgemaildb-psarna.turso.io";
req.open("POST", url);
const readonly_token =
"eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJhIjoicm8iLCJpYXQiOjE2ODE4MjkxNDMsImlkIjoiNzIyY2IyYTEtY2M3MC0xMWVkLWFkM2MtOGVhNWEwNjcyYmM2In0.T55UgAMs9vP2zMI_AhOiD2AONj_bsnDNRjZiBBWUb2gKU5MEjJoW8uHbtMGqpJ0312SULpsWTWdEJ886oSjGCQ";
req.setRequestHeader("Authorization", "Bearer " + readonly_token);
const msg = JSON.stringify({
statements: [
{
q: "SELECT date, sender, recipients, data FROM mail WHERE recipients = ? ORDER BY ROWID DESC LIMIT ? OFFSET ?",
params: ["<" + user + "@idont.date>", PAGE_SIZE, offset],
},
],
});
req.send(msg);
Этот запрос является операцией SELECT, поэтому для него требуется доступ к базе данных только для чтения. Это позволяет использовать маркер аутентификации только для чтения, который можно безопасно «слить» в источник HTML веб-страницы.
Исходный код
Весь исходный код, включая сервер, клиент и тесты, является открытым и доступен здесь. Наслаждайтесь! Чтобы запустить сервер, выполните cargo run. Испытания могут проводиться с cargo test.
Развертывание
SMTP-сервер
Теперь, когда сервер реализован, пришло время сделать его общедоступным. Для этого вам понадобится любой компьютер с открытым IPv4-адресом и портом 25, открытым для входящего трафика. С технической точки зрения SMTP работает также и на IPv6, но на практике поставщики электронной почты часто отказываются работать с IPv6-серверами в качестве предотвращения нежелательной почты. Адресное пространство IPv6 является слишком широким и слишком нерегулируемым, чтобы его было так же легко проверить, как IPv4, поэтому лучше придерживаться этого. SMTP-серверы часто прослушивают соединения на других номерах портов, включая 465 и даже 2525, но поскольку 25 поддерживается всеми поставщиками электронной почты, достаточно придерживаться этого.
Сервер электронной почты весит меньше, чем 5MiB целом, и работает просто прекрасно на моем древнем Raspberry Pi 2 - если он доступен через общедоступный IPv4-адрес в порту 25, вы готовы.
Конфигурация DNS
Чтобы стать поставщиком почтовых ящиков, необходим домен. К счастью, с таким количеством доступных доменов верхнего уровня, вы можете получить один по действительно доступной цене.
Как только вы становитесь гордым владельцем домена Интернета, необходимо настроить две записи DNS, чтобы сделать ваш сервер работоспособным. Ниже приведены примеры настройки домена idont.date и IP-адреса машины, которую я использую, поэтому убедитесь, что вы соответственно заменили их своим именем домена и IP-адресом.
- Запись A, указывающая на IP-адрес сервера, например
[A] [smtp.idont.date] [3.143.135.0]
- Запись MX (обмен почтой), которая указывает на запись A выше, например
[MX] [idont.date] [smtp.idont.date]
С этими двумя записями, серверы, которые собираются отправить вам почту, смогут узнать, куда направить информацию. Сервер электронной почты просто разрешит записи DNS и выяснит, на какой IP-адрес он должен отправить всю информацию.
Вот и все! После настройки сервера и DNS сервера всё готово к приему почты. Вы можете попробовать это, отправив электронное письмо на ваш новый домен или подписавшись на ту рассылку, которую вы всегда так ненавидели.