Всем привет!

Мы продолжаем писать веб-сервис на Rust. Оглавление:

Часть 1: прототип
Часть 2: читаем INI; multirust
Часть 3: обновляем базу из консоли
Часть 4: переходим к REST API
Часть 5 (эта): обработчики, рефакторинг, и макросы

Теперь мы рассмотрим собственно обработчики запросов к API и перепишем предыдущий, страшный код. И вообще, это последняя статья из цикла, поэтому здесь будут рефакторинг, стиль, макросы и все-все-все. Это самая длинная часть.

Почему мы клонировали Arc дважды


Вот как сейчас выглядит код, настраивающий пути к API:

        let sdb = Arc::new(Mutex::new(db));
        let mut router = router::Router::new();
        {
            let sdb_ = sdb.clone();
            router.get("/api/v1/records",
                move |req: &mut Request|
                handlers::get_records(sdb_.clone(), req));
        }
        {
            let sdb_ = sdb.clone();
            router.get("/api/v1/records/:id",
                move |req: &mut Request|
                handlers::get_record(sdb_.clone(), req));
        }
        …

Для начала — сами обработчики. Вот, например, handlers::get_records():

handlers::get_records
pub fn get_records(sdb: Arc<Mutex<Connection>>, req: &mut Request) -> IronResult<Response> {
    let url = req.url.clone().into_generic_url();
    let mut name: Option<String> = None;
    if let Some(qp) = url.query_pairs() {
        for (key, value) in qp {
            match (&key[..], value) {
                ("name", n) => {
                    if let None = name {
                        name = Some(n);
                    } else {
                        return Ok(Response::with((status::BadRequest, "passed name in query more than once")));
                    }
                }
                _ => return Ok(Response::with((status::BadRequest, "unexpected query parameters"))),
            }
        }
    } else {
        return Ok(Response::with((status::BadRequest, "passed names don’t parse as application/x-www-form-urlencoded or there are no parameters")));
    }

    let json_records;
    if let Ok(recs) = ::db::read(sdb, name.as_ref().map(|s| &s[..])) {
        use rustc_serialize::json;
        if let Ok(json) = json::encode(&recs) {
            json_records = Some(json);
        } else {
            return Ok(Response::with((status::InternalServerError, "couldn't convert records to JSON")));
        }
    } else {
        return Ok(Response::with((status::InternalServerError, "couldn't read records from database")));
    }
    let content_type = Mime(
        TopLevel::Application, SubLevel::Json, Vec::new());

    Ok(Response::with(
        (content_type, status::Ok, json_records.unwrap())))
}


Его сигнатура — причина того, что нам пришлось клонировать Arc с базой данных внутри замыкания:

pub fn get_records(sdb: Arc<Mutex<Connection>>, req: &mut Request) -> IronResult<Response> {

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

Что происходит в обработчиках


В целом, обработчики однотипны, поэтому я относительно подробно рассмотрю только get_records — он самый сложный. Хочу отметить, в обработчиках активно используется сопоставление с образцом для определения ошибочных ситуаций.

Сначала мы получаем Url в формате rust-url из Url Iron’а.

    let url = req.url.clone().into_generic_url();

Мы делаем это, чтобы затем воспользоваться методом query_pairs, который разбирает URL как данные application/x-www-form-urlencoded и (возможно) возвращает итератор по парам ключ-значение.

if let


Сейчас я покажу новый синтаксис «if let», а затем расскажу, в чём его суть.

    if let Some(qp) = url.query_pairs() {
        for (key, value) in qp {

Вы, возможно, уже догадались, что означает эта запись. Оператор «if let» делает попытку сопоставления с образцом, и, если она успешна, передаёт исполнение в блок за if let. В этом блоке будет доступно имя, с которым мы только что связали значение — в данном случае, qp. Если же сопоставить значение с шаблоном не удалось (query_pairs() вернул None), то выполняется ветвь else — похоже на обычный if.

Возврат ошибочных HTTP-статусов


Соответственно, если итератор нам не вернули, это ошибка:

    } else {
        return Ok(Response::with((status::BadRequest, “passed names don’t parse as application/x-www-form-urlencoded or there are no parameters”)));
    }

Здесь у нас в скобках кортеж, описывающий ответ сервера: HTTP-статус и сообщение.

Получаем параметры запроса


Если же нам вернули итератор, мы обходим его, чтобы получить параметр name и сохранить его в переменную name:

    let mut name: Option<String> = None;
    if let Some(qp) = url.query_pairs() {
        for (key, value) in qp {
            match (&key[..], value) {
                ("name", n) => {
                    if let None = name {
                        name = Some(n);
                    } else {
                        return Ok(Response::with((status::BadRequest, "passed name in query more than once")));
                }
            }
            _ => return Ok(Response::with((status::BadRequest, "unexpected query parameters"))),
            }
        }
    }

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

Убираем цикл по параметрам


.query_pairs() на самом деле возвращает Option<Vec<(String, String)>>. Поэтому мы можем просто проверить длину вектора и имя единственного параметра:

    let mut name: Option<String> = None;
    if let Some(mut qp) = url.query_pairs() {
        if qp.len() != 1 {
            return Ok(Response::with((status::BadRequest, "passed more than one parameter or no parameters at all")));
        }
        let (key, value) = qp.pop().unwrap();
        if key == "name" {
            name = Some(value);
        }
    } else {

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

Здесь есть важный момент:

        let (key, value) = qp.pop().unwrap();

Принципиально использовать pop() — он передаёт нам элемент вектора с владением. Обычное обращение по индексу (qp[0]) дало бы ссылку, и мы не смогли бы переместить value из пары в Some(value), чтобы положить всё это в name.

Почему работает сравнение String со &str?


Стоит также отметить, что в нашем векторе хранятся пары (String, String). Но дальше мы напрямую сравниваем key с «name» — строковым литералом:

        if key == "name" {
            name = Some(value);
        }

Он, как вы помните, имеет тип &'static str. Это работает, потому что String реализует типаж PartialEq для сравнения с &'a str:

impl<'a> PartialEq<&'a str> for String

Поэтому здесь не происходит никаких преобразований типов.

Если же такого типажа не было бы, мы могли бы преобразовать String в &str с помощью синтаксиса срезов: &key[..] вернёт срез по всей строке, т.е. ссылку-&str с тем же содержимым.

Далее мы производим собственно доступ к базе данных.

Неинициализированные переменные — это опасно?


Сначала объявим имя для JSON-записей, которые должна будет вернуть наша точка доступа REST:

    let json_records;

Хм, мы не инициализируем его никаким значением? Хотим выстрелить себе в ногу?

Нет, Rust не даст нам использовать объявленное имя, пока оно не будет инициализировано. Например, в таком коде

fn main() {
    let a;
    if true {
        a = 5;
    } else {
        println!("{}", a);
    }
}

произойдёт ошибка:

test.rs:6:24: 6:25 error: use of possibly uninitialized variable: `a` [E0381]
test.rs:6         println!("{}", a);
                                 ^


Читаем записи из БД. Пользуемся Option::map


Далее мы читаем записи из базы данных:

    if let Ok(recs) = ::db::read(sdb, name.as_ref().map(|s| &s[..])) {

Почему в аргументах происходит нечто странное?

                                      name.as_ref().map(|s| &s[..])

Сейчас я объясню. Сначала посмотрим на сигнатуру нашей ::db::read():

pub fn read(sdb: Arc<Mutex<Connection>>, name: Option<&str>) -> Result<Vec<Record>, ()> {

Как видите, она принимает name в виде Option<&str>. Наш же name имеет тип Option<String>. Но не беда: метод .as_ref() превращает Option в Option<&T> — таким образом, мы получаем Option<&String>.

К сожалению, т.к. &String завёрнут в Option, он не преобразуется в &str автоматически. Поэтому мы используем вышеупомянутый синтаксис срезов в анонимной функции:

                                                   .map(|s| &s[..])

.map применяет функцию к содержимому Option и преобразует T из Option<T> в некоторый другой тип. В данном случае, мы преобразуем &String в &str. Это похоже на хаскельный fmap :: Functor f => (a -> b) -> f a -> f b.

Есть тонкость: мы не могли вызвать .map сразу на name: Option<T>, т.к. тогда ссылка будет валидна только в области видимости параметров функции при вызове. В таком случае мы получим ссылку внутри замыкания, и жить она будет только столько, сколько живёт замыкание. А оно нигде не сохраняется и будет уничтожено после передачи параметра в функцию. Такая ссылка будет временным объектом:

handlers.rs:25:53: 25:54 error: `s` does not live long enough
handlers.rs:25     if let Ok(recs) = ::db::read(sdb, name.map(|s| &s[..])) {
                                                                   ^
handlers.rs:25:23: 25:60 note: reference must be valid for the call at 25:22...
handlers.rs:25     if let Ok(recs) = ::db::read(sdb, name.map(|s| &s[..])) {
                                     ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
handlers.rs:25:52: 25:58 note: ...but borrowed value is only valid for the scope of parameters for function at 25:51
handlers.rs:25     if let Ok(recs) = ::db::read(sdb, name.map(|s| &s[..])) {
                                                                  ^~~~~~

В случае с .as_ref() же ссылка живёт, пока живёт сам Option, поэтому всё работает.

А что же многопоточность?


Давайте заглянем в ::db::read и посмотрим, как работает хвалёная защита от гонок данных.

    if let Ok(rs) = show(&*sdb.lock().unwrap(), name) {
        Ok(rs)
    } else {
        Err(())
    }

Мы хотим вызвать show:

pub fn show(db: &Connection, arg: Option<&str>) -> ::postgres::Result<Vec<Record>> {

Эта функция принимает ссылку на Connection, а у нас Arc<Mutex<Connection>>. Мы никак не сможем добраться до интересующего нас соединения с БД, кроме как развернув счётчик ссылок и завладев мьютексом. Система типов делает неверные состояния непредставимыми.

Почти магия


Итак, мы хотим завладеть мьютексом. Он вложен в счётчик ссылок. Здесь в дело вступают две вещи: преобразование при разыменовании и авто-разыменование при вызове методов.

Пока проигнорируем странное &* и посмотрим на сам sdb.lock(). sdb — это Arc, но Arc<T> реализует Deref<T>.

impl<T> Deref for Arc<T> where T: ?Sized
  type Target = T
  fn deref(&self) -> &T

Таким образом, Arc<T> будет автоматически преобразовано в &T, если это необходимо. Это даст нам &Mutex<Connection>.

Дальше в дело вступает авто-разыменование при вызове методов. Вкратце, компилятор вставит столько разыменований при вызове метода, сколько надо.

Вот простой пример:

struct Foo;

impl Foo {
    fn foo(&self) { println!("Foo"); }
}

let f = Foo;

f.foo();
(&f).foo();
(&&f).foo();
(&&&&&&&&f).foo();

Все четыре последние строки делают одно и то же.

Безопасное освобождение мьютекса с помощью RAII


Mutex::lock вернёт нам LockResult<MutexGuard<T>>. Result позволяет нам обработать ошибку, а MutexGuard<T> — это RAII-значение, которое автоматически откроет мьютекс, как только мы перестанем с ним работать.

То самое &* преобразует MutexGuard<T> в &T – сначала мы его разыменовываем и получаем T, а затем берём адрес чтобы получить обычную ссылку, &T.

Почему lock() может напрямую работать с Arc<Mutex<Connection>>, а MutexGuard нужно преобразовывать вручную? Потому что lock – это метод, а вызов методов на самом деле будет не только разыменовывать ссылки, но и преобразовывать одни ссылки в другие (т.е. делать аналог &*). При передаче аргументов в функцию это надо делать вручную.

Сериализация


После получения наших записей мы хотим сериализовать их в JSON. Для этого воспользуемся rustc_serialize:

        use rustc_serialize::json;

Как видите, мы можем импортировать модули не только глобально, но и в области видимости отдельной функции или блока. Это помогает не засорять глобальное пространство имён.

Сама сериализация делается этим кодом:

        if let Ok(json) = json::encode(&recs) {
            json_records = Some(json);
        }
        ...

При этом код сериализатора генерируется автоматически! Нам нужно лишь объявить тип наших записей, как сериализуемый (и заодно, десериализуемый):

#[derive(RustcEncodable, RustcDecodable)]
pub struct Record {
    id: Option<i32>,
    pub name: String,
    pub phone: String,
}


Отправляем всё назад


Наконец, мы оборачиваем наш JSON в правильный HTTP с соответствующими заголовками и возвращаем его:

    let content_type = Mime(
        TopLevel::Application, SubLevel::Json, Vec::new());

    Ok(Response::with(
        (content_type, status::Ok, json_records.unwrap())))

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

В целом же, наша программа закончена! Теперь нашу замечательную телефонную книгу можно обновлять не только из командной строки, но и через модное веб-API. Если хотите посмотреть, как всё работает, возьмите версию кода по тегу feature-complete с GitHub.

Рефакторинг не так сложен, и я показываю этот процесс просто затем, чтобы убедить вас, что код на Rust тоже может быть красивым. Тот нечитабельный бардак, который мы развели в процессе реализации функционала — это просто результат спешки. Rust в этом не виноват — на нём можно писать элегантный код.

Не клонируем клонов


В первую очередь, давайте разберёмся с тем двойным клонированием Arc, о котором я говорил в предыдущей части:

                    {
                        let sdb_ = sdb.clone();
                        router.get("/api/v1/records",
                                   move |req: &mut Request|
                                   handlers::get_records(sdb_.clone(), req));
                    }

Это очень просто победить. Изменим сигнатуру handlers::get_records с

pub fn get_records(sdb: Arc<Mutex<Connection>>, req: &mut Request) -> IronResult<Response> {

на

pub fn get_records(sdb: &Mutex<Connection>, req: &mut Request) -> IronResult<Response> {

И вообще, используем &Mutex<Connection> везде — в обработчиках и в функциях работы с БД. Всё, двойное клонирование больше не нужно:

                    {
                        let sdb = sdb.clone();
                        router.get("/api/v1/records",
                                   move |req: &mut Request|
                                   handlers::get_records(&*sdb, req));
                    }

С огромным main тоже стоит разобраться. Просто выносим все действия в свои функции и получаем классный компактный main:

fn main() {
    let (params, sslmode) = params();
    let db = Connection::connect(params, &sslmode).unwrap();

    init_db(&db);

    let args: Vec<String> = std::env::args().collect();

    match args.get(1) {
        Some(text) => {
            match text.as_ref() {
                "add" => add(&db, &args),
                "del" => del(&db, &args),
                "edit" => edit(&db, &args),
                "show" => show(&db, &args),
                "help" => println!("{}", HELP),
                "serve" => serve(db),
                command => panic!(
                    format!("Invalid command: {}", command))
            }
        }
        None => panic!("No command supplied"),
    }
}


rustfmt!


Напоследок сладкое: rustfmt! Утилита форматирования исходного кода на Rust ещё не закончена, но уже годится для украшения кода нашего маленького проекта.

Склонировав репозиторий, сделаем cargo build --release, а затем скопируем полученный исполняемый файл куда-нибудь в $PATH. Затем, в корне нашего проекта сделаем

$ rustfmt src/main.rs

И всё, код всего проекта мгновенно отформатирован! rustfmt следует по ссылкам на другие модули и форматирует и их тоже.

В отличие от gofmt, rustfmt позволяет довольно подробно настроить стиль, в котором исходник будет переписан.

Нынешний стиль по-умолчанию — примерно такой, в котором написан сам компилятор. Однако по мере доработки официального руководства по стилю, rustfmt тоже будет допилен.

На этом «разумный» рефакторинг заканчивается, и начинается… нечто спорное, но однозначно весёлое: давайте уберём оставшееся повторение похожего кода с помощью макросов.

Макросы


Про какое повторение я говорю? Про это:

    {
        let sdb = sdb.clone();
        router.get("/api/v1/records",
                   move |req: &mut Request|
                   handlers::get_records(&*sdb, req));
    }
    {
        let sdb = sdb.clone();
        router.get("/api/v1/records/:id",
                   move |req: &mut Request|
                   handlers::get_record(&*sdb, req));
    }
    {
        let sdb = sdb.clone();
        router.post("/api/v1/records",
                    move |req: &mut Request|
                    handlers::add_record(&*sdb, req));
    }
    {
        let sdb = sdb.clone();
        router.put("/api/v1/records/:id",
                   move |req: &mut Request|
                   handlers::update_record(&*sdb, req));
    }
    {
        let sdb = sdb.clone();
        router.delete("/api/v1/records/:id",
                      move |req: &mut Request|
                      handlers::delete_record(&*sdb, req));

    }

Очевидно, тут есть некоторая высокоуровневая структура, которую нам не удалось отразить в коде. Поскольку эти блоки отличаются методом, который нужно вызывать у router, для обработки всех этих вариантов в обычной функции понадобился бы match по некоторому перечислению, который вызывал бы соответствующий метод в зависимости от аргумента.

Это, вообще говоря, вариант, и скорее всего я бы попытался сделать это, если бы писал этот код на работе, но тут мы веселимся, а мне давно хотелось попробовать макросы в Rust. Так что приступим.

Для начала, повторяющаяся структура тут — это блок, который клонирует Arc и затем выполняет оператор. Попробуем написать соответствующий макрос:

macro_rules! clone_pass_bound {
    ($arc:ident, $stmt:stmt) => {
        {
            let $arc = $arc.clone();
            $stmt;
        }
    }
}

Первая строка говорит, что мы начали определять макрос под названием clone_pass_bound. Дурацкое название, но лучше придумать не получилось. Это — само по себе симптом того, что так, наверное, не стоит делать в рабочем коде. Но да ладно — это сейчас не наш случай.

Макросы в Rust типизированные, и наш принимает два аргумента — $arc типа «идентификатор» (ident) и $stmt типа «оператор» (statement, stmt). Если присмотреться, можно заметить похожесть определения макроса на match — здесь сопоставляется определённая комбинация аргументов определённому телу. Ветвей у макроса может быть много, как и у match — и это полезно в случае рекурсии.

После стрелки идут две пары фигурных скобок. Одни требуются согласно синтаксису описания макроса — в общем, как и в обычном match.

С помощью второй пары мы говорим, что наш макрос раскрывается в блок. Внутри блока пишем практически обычный код, заменяя sdb на $arc. Это тривиальное обобщение. За клонированием следует наш оператор.

Вот как этот макрос вызывается:

    clone_pass_bound!(
        sdb,
        router.get("/api/v1/records",
                   move |req: &mut Request|
                   handlers::get_records(&*sdb, req)));

Пока мы ничего не сэкономили по объёму, только получили непонятный вызов. Но не отчаиваемся — мы только начали!

Макрос на макросе


Теперь становится видно, что один обработчик можно описать с помощью четырёх параметров: соединение с БД, router, какой метод ему добавлять (get, post, и т.д.), и как называется определённый нами обработчик. Напишем макрос для этого:

macro_rules! define_handler {
    ($connection:ident, $router: ident.$method:ident, $route:expr,
     $handler:path) => {
        clone_pass_bound!(
            $connection,
            $router.$method(
                $route,
                move |req: &mut Request|
                $handler(&*$connection, req)));
    }
}

Здесь в первую очередь стоит ещё раз подчеркнуть сходство вызова макроса с обычным сопоставлением с образцом. Как видите, разделителем аргументов макроса не обязательно должна быть запятая — router и его метод мы разделили точкой, для большего сходства с обычным кодом.

Затем тупо заменяем все конкретные имена на мета-переменные макроса и вызываем наш предыдущий макрос — не так уж страшно и сложно. Оба этих макроса я вообще написал с первой попытки.

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

    define_handler!(sdb, router.get, "/api/v1/records", handlers::get_records);

    define_handler!(sdb, router.get, "/api/v1/records/:id",
                    handlers::get_record);

    define_handler!(sdb, router.post, "/api/v1/records", handlers::add_record);

    define_handler!(sdb, router.put, "/api/v1/records/:id",
                    handlers::update_record);

    define_handler!(sdb, router.delete, "/api/v1/records/:id",
                    handlers::delete_record);

Это не предел — сейчас мы определим последний макрос, который сделает наше определение очень компактным и довольно понятным. Теперь изменяющиеся части кода совсем очевидны, и сделать код совсем DRY ничто не помешает.

В нашем последнем макросе будет ровно 1 (один) нетривиальный момент.

Макросом погоняет


Вот как выглядит последний макрос:

macro_rules! define_handlers_family {
    ($connection:ident, $router:ident,
     $( [$method:ident, $route:expr, $handler:path]),+ ) => {
        $( define_handler!($connection, $router.$method, $route, $handler); )+
    }
}

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

    ($connection:ident, $router:ident,
     $( [$method:ident, $route:expr, $handler:path]),+ ) => {

$( … ),+ означает, что заключённая в скобках группа должна повторяться один или более раз при вызове этого макроса. Похоже на регулярные выражения.

Далее — тело нашего макроса-монстра. Сначала я написал так:

        define_handler!($connection, $router.$method, $route, $handler);

На что компилятор возразил:

main.rs:134:46: 134:53 error: variable 'method' is still repeating at this depth
main.rs:134         define_handler!($connection, $router.$method, $route, $handler);
                                                         ^~~~~~~

Как я говорил, часть вызова, определяющая $method, $route и $handler может повторяться. В макросах Rust действует правило, что мета-переменная, находящаяся на определённом «уровне» повторений в вызове, должна находится на том же уровне повторений при использовании.

Об этом можно думать так — кортежи параметров макро-вызовов перебираются одновременно с соответствующими телами. Т.е. одному набору параметров должно соответствовать одно тело. Таким образом становится легче понять структуру макроса — тело становится похоже на вызов.

А сейчас у нас макрос записан так, будто у него всего одно тело — получается, что параметры вызова повторяются, а тела повторить нельзя. Тогда, какой же именно $method должен быть в теле? Непонятно. Вот для избежания таких ситуаций и придумано правило перебора параметров «в ногу» с телами.

Для нас это всё значит, что нужно обернуть тело в такой же модификатор повторяемости, как у параметров:

        $( define_handler!($connection, $router.$method, $route, $handler); )+

Теперь $method, $route и $handler соответствуют повторяющимся параметрам. А $connection и $router «глобальны» — они не находятся ни под одним модификатором повторяемости, поэтому они будут дублироваться в каждом теле.

В награду за этот мозговынос мы получаем красивое определение всех путей в нашем API:

    define_handlers_family!(
        sdb, router,
        [get, "/api/v1/records", handlers::get_records],
        [get, "/api/v1/records/:id", handlers::get_record],
        [post, "/api/v1/records", handlers::add_record],
        [put, "/api/v1/records/:id", handlers::update_record],
        [delete, "/api/v1/records/:id", handlers::delete_record]);

Никакого лишнего дублирования, и в окончательном варианте выглядит даже относительно понятно для непосвящённого.

Хочу отметить, что макросы в Rust гигиеничны — столкновения имён внутри макроса с именами снаружи исключены.

Ах да, чуть не забыл — в отладке макросов очень помогает опция компилятора --pretty-print=expand. Так он напечатает код после раскрытия макросов в поток стандартного вывода. Похоже на опцию -E у компиляторов Си и C++.

До новых встреч!


На этом всё. Теперь уже совсем всё — я думаю, этот цикл статей рассказал достаточно, чтобы вы сами могли начать строить свой код на Rust, в том числе для веба.

Если вы уже начали делать что-то на Rust — пишите об этом в комментариях. А также заходите к нам в чат с вопросами, возникающими по ходу дела — вам там рады помочь.

Комментарии (3)


  1. tgz
    02.11.2015 20:04

    Great!


  1. frol
    03.11.2015 05:25
    +1

    Просто оставлю это здесь: есть удобный сайт со статусом готовности Rust к Web — arewewebyet.com:

    You can use Rust for web stuff, but the ecosystem isn’t mature yet.


    1. mkpankov
      03.11.2015 12:54

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

      Например, недавно появился mail sender — lettre.