2021/11/14

Rustでマルチパート(html+fallback text)メールの送信

rustemail

以下のような機能を持つメール送信処理をRustで実装してみました。

  • sendgridでメールが送信できる(SMTPリレー方式)(参考: How to Send an SMTP Email - ドキュメント | SendGrid
    • SMTPリレー方式で実装しておくと、host名などを変更することで種々のサービスに簡単に変更できるメリットがあります。(Web API方式を使うとベンダーを変更するときにちょっとだけ大変です。)
  • HTMLとTEXTメールを同時に送ることができる(Multipart対応)
  • メールのテンプレートは、事前にリソースフォルダに格納して利用できる
  • テンプレートエンジンはjsonデータ的なものを引数にとって、簡単にレンダリングできる

実際には以下の2つのライブラリを組み合わせることで比較的容易に実現できました。

  • lettre : メール送信クライアントライブラリ
  • tera : テンプレートエンジン

以下詳細です。

依存関係

  • once_cellはアプリケーション起動時(サーバー起動時等)に設定ファイルの値からメールテンプレートの格納Pathを取得して、テンプレートを動的に読み直し、staticフィールドに代入できるようにするためのものです。
  • anyhowは便利なエラーラッパーツールで常用しています。(ここはお好みで変更可能です。)
lettre = "0.10.0-rc.4"
tera = "1.15.0"
once_cell = "1.8.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = { version = "1.0", features = ["backtrace"] }

テンプレートエンジンの利用方法

テンプレートの準備

以下のような感じで、事前にtemplateを準備しておきます。

※ htmlメールに関してはmjmlでコーディングして、コマンドライン等でビルドしてhtmlファイルを作っておきます。(mjmlは利用する必要な特にないですが、htmlメールのコーディングにめちゃくちゃ便利なので使っています。)

※ また、私が現在開発しているアプリケーションは複数言語対応が必要なため、複数言語に対応したメールテンプレートを準備しています。(こちらも必要に応じてフォルダ分けしています。)

.
├── Cargo.toml
├── src
│   ├── lib.rs
│   └── user_reset_password.rs
└── templates
    ├── html
    │   ├── build
    │   │   ├── en
    │   │   │   └── user_reset_password.html
    │   │   └── ja
    │   │       └── user_reset_password.html
    │   └── src
    │       ├── en
    │       │   └── user_reset_password.mjml
    │       └── ja
    │           └── user_reset_password.mjml
    └── text
        ├── en
        │   └── user_reset_password.txt
        └── ja
            └── user_reset_password.txt

テンプレートの読み込み

use anyhow::Context;
use once_cell::sync::OnceCell;
use serde_json::value::Value;
use std::path::PathBuf;
use tera::{Context as TeraContext, Tera};

pub type Result<T> = anyhow::Result<T>;

// HTMLとTEXTのテンプレート定義をglobal領域に定義しておきます。
static HTML_TEMPLATES: OnceCell<Tera> = OnceCell::new();
static TEXT_TEMPLATES: OnceCell<Tera> = OnceCell::new();

// ルートディレクトリを指定して、テンプレートファイルの読み込みを行います。
pub fn init_templates(root_dir_path: Option<PathBuf>) -> Result<()> {
    let root_dir = root_dir_path.unwrap_or_else(|| {
        let mut result = std::env::current_dir().unwrap();
        result.push("templates");
        result
    });
    let _ok = init_html_templates(&root_dir)?;
    let _ok = init_text_templates(&root_dir)?;
    Ok(())
}

fn init_html_templates(root_dir: &PathBuf) -> Result<()> {
    match HTML_TEMPLATES.get() {
        Some(_) => Ok(()),
        None => {
            let mut html_templates_dir = root_dir.clone();
            html_templates_dir.push("html/build/**/*");
            let html_templates_dir = html_templates_dir.into_os_string().into_string().unwrap();
            let html_templates = Tera::new(html_templates_dir.as_str()).with_context(|| {
                format!(
                    "Failed to load html templates of \"{}\"",
                    html_templates_dir
                )
            })?;
            HTML_TEMPLATES
                .set(html_templates)
                .expect("html templates must be stored.");
            Ok(())
        }
    }
}

fn init_text_templates(root_dir: &PathBuf) -> Result<()> {
    match TEXT_TEMPLATES.get() {
        Some(_) => Ok(()),
        None => {
            let mut text_templates_dir = root_dir.clone();
            text_templates_dir.push("text/**/*");
            let text_templates_dir = text_templates_dir.into_os_string().into_string().unwrap();
            let text_templates = Tera::new(text_templates_dir.as_str()).with_context(|| {
                format!(
                    "Failed to load text templates of \"{}\"",
                    text_templates_dir
                )
            })?;
            TEXT_TEMPLATES
                .set(text_templates)
                .expect("text templates must be stored.");
            Ok(())
        }
    }
}

// HTMLテンプレート名と、データを指定してレンダリングします。結果を文字列で返します。
pub fn render_html(template: &str, data: Value) -> Result<String> {
    let context = TeraContext::from_value(data).context("could not create context from value")?;
    let html_templates = HTML_TEMPLATES
        .get()
        .expect("html templates must be initialized...");
    html_templates
        .render(template, &context)
        .with_context(|| format!("could not render html mail template of {}.", template))
}

// TEXTテンプレート名と、データを指定してレンダリングします。結果を文字列で返します。
pub fn render_text(template: &str, data: Value) -> Result<String> {
    let context = TeraContext::from_value(data).context("could not create context from value")?;
    let text_templates = TEXT_TEMPLATES
        .get()
        .expect("text templates must be initialized...");
    text_templates
        .render(template, &context)
        .with_context(|| format!("could not render html mail template of {}.", template))
}

こちらは、特段メールに限った話だけでなく、HTMLのレンダリングなどにも流用できると思います。
(最近はクライアントアプリはNextJSとかiOS/Android等に代表される別のアプリケーションで動作することが多いので、そういう用途はめっきり減りましたが)

メールの送信部分

pub struct Sender {
    from: String,
    smtp_host: String,
    smtp_port: u16,
    smtp_user: String,
    smtp_password: String,
}

impl Sender {
    pub fn new(
        from: String,
        smtp_host: String,
        smtp_port: u16,
        smtp_user: String,
        smtp_password: String,
    ) -> Self {
        Self {
            from,
            smtp_host,
            smtp_port,
            smtp_user,
            smtp_password,
        }
    }
}

impl Sender {
    fn send_email_by_template(
        &self,
        to: &str,
        subject: &str,
        locale: &str,
        template_name: &str,
        data: Value,
    ) -> Result<()> {
        // localeとメールの種類からテンプレート名を作ります。(テキストメール用)
        let text_template_name = format!("{}/{}.txt", locale, template_name);
        // テキストメールの本文をレンダリングします。
        let text_body = templates::render_text(text_template_name.as_str(), data.clone())?;
        // localeとメールの種類からテンプレート名を作ります。(HTMLメール用)
        let html_template_name = format!("{}/{}.html", locale, template_name);
        // HTMLメールの本文をレンダリングします。
        let html_body = templates::render_html(html_template_name.as_str(), data)?;
        // メール送信処理を実行します。
        self.send_email(to, subject, text_body, html_body)
    }

    // TODO replyとかccとかattachmentをつけたい場合は、ここを拡張する
    fn send_email<T: IntoBody>(
        &self,
        to: &str,
        subject: &str,
        text_body: T,
        html_body: T,
    ) -> Result<()> {
        let email = Message::builder()
            .from(self.from.parse().unwrap())
            .to(to.parse().unwrap())
            .subject(subject)
            .multipart(
                MultiPart::alternative()
                    .singlepart(
                        SinglePart::builder()
                            .header(header::ContentType::TEXT_PLAIN)
                            .body(text_body),
                    )
                    .singlepart(
                        SinglePart::builder()
                            .header(header::ContentType::TEXT_HTML)
                            .body(html_body),
                    ),
            )
            .unwrap();

        let credentials = Credentials::new(self.smtp_user.clone(), self.smtp_password.clone());

        // Open a remote connection to sendgrid
        let mailer = SmtpTransport::starttls_relay(self.smtp_host.as_str())
            .unwrap()
            .port(self.smtp_port)
            .credentials(credentials)
            .build();

        match mailer.send(&email) {
            Ok(_) => Ok(()),
            Err(err) => Err(anyhow!("got smtp send error: {:?}", err)),
        }
    }
}

個別のメール送信処理

メールの種類ごとに、レンダリング用のデータのための構造体(serializable)と送信処理を定義します。

use crate::{Result, Sender};
use anyhow::Context;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UserResetPasswordEmailData {
    pub reset_password_link_url: String,
    pub valid_hours: i32,
}

impl Sender {
    pub fn send_user_reset_password_email(
        &self,
        to: &str,
        locale: &str,
        data: UserResetPasswordEmailData,
    ) -> Result<()> {
        let subject = match locale {
            "en" => "Please reset your password!",
            _ => "パスワードを変更してください!",
        };
        let data = serde_json::value::to_value(data.clone())
            .with_context(|| format!("could not serialize to json of {:?}", data))?;
        self.send_email_by_template(to, subject, locale, "user_reset_password", data)
    }
}

// unit testで実際にsendgridにメールを送信できるかチェックします。(送りすぎると課金されるので注意してください!!)
#[cfg(test)]
mod test {
    use super::*;
    use crate::*;

    #[test]
    fn test_send_user_reset_password_email() {
        let _ = init_templates(None).unwrap();
        let sender = Sender::new(
            "[email protected]".into(),
            "smtp.sendgrid.net".into(),
            587,
            "apikey".into(),
            "SG.Your-Sendgrid-API-Secret".into(),
        );
        let data = UserResetPasswordEmailData {
            reset_password_link_url: "http://localhost:3000/reset_password?token=XXXX".to_owned(),
            valid_hours: 24,
        };
        let result = sender.send_user_reset_password_email("[email protected]", "en", data);
    }
}

感想

マルチパートのEメール送信処理をRustでやるとちょっと大変かなと予想していたのですが、templateエンジンやメール送信処理は結構ライブラリが豊富なので、比較的楽に実装できました。

また、singletonの定義などがonce_cellを使って楽に実装できるようになっていたのが嬉しかったです。

以上です。