Stalwart Mail Server 与 Actix-Web + Yew 项目集成指南

Stalwart Mail Server 与 Actix-Web + Yew 项目集成指南

Stalwart Mail Server 与 Actix-Web + Yew 项目集成指南

概述

本指南介绍如何将 Stalwart Mail Server 集成到基于 Actix-Web 后端和 Yew 前端的项目中,实现完整的邮件收发功能。

架构设计


┌─────────────┐    HTTP/REST     ┌─────────────┐    JMAP API       ┌──────────────────┐

│   Yew       │ ───────────────> │  Actix-Web  │ ───────────────> │ Stalwart Mail    │

│  前端        │                  │  后端服务    │                  │ Server           │

└─────────────┘                  └─────────────┘                  └──────────────────┘

                                      │                                      │

                                      │ SQL查询                               │ 存储邮件

                                      ▼                                      ▼

                               ┌─────────────┐                        ┌─────────────┐

                               │ PostgreSQL  │ <───────────────────── │ PostgreSQL  │

                               │  项目数据库  │                        │  邮件数据库  │

                               └─────────────┘                        └─────────────┘

1. Stalwart Mail Server 部署与配置

1.1 Docker 部署

创建 docker-compose.yml 文件:


version: '3.8'



services:

  stalwart-mail:

    image: stalwartlabs/mail-server:latest

    container_name: stalwart-mail

    hostname: mail.yourdomain.com

    restart: unless-stopped

    

    ports:

      - "25:25"    # SMTP

      - "587:587"  # Submission

      - "993:993"  # IMAPS

      - "80:80"    # JMAP/HTTP

      - "443:443"  # JMAP/HTTPS

    

    environment:

      - STALWART_HOSTNAME=mail.yourdomain.com

      - STALWART_DATABASE_URL=postgresql://mail_user:mail_password@postgres-mail:5432/mail_db

      - STALWART_JWT_SECRET=your-very-secret-jwt-key-here

    

    volumes:

      - ./stalwart/config:/etc/stalwart

      - ./stalwart/data:/var/lib/stalwart

      - ./stalwart/tls:/etc/stalwart/tls:ro

    

    depends_on:

      - postgres-mail



  postgres-mail:

    image: postgres:15-alpine

    container_name: postgres-mail

    restart: unless-stopped

    environment:

      - POSTGRES_DB=mail_db

      - POSTGRES_USER=mail_user

      - POSTGRES_PASSWORD=mail_password

    volumes:

      - postgres-mail-data:/var/lib/postgresql/data

      - ./init-scripts:/docker-entrypoint-initdb.d:ro



volumes:

  postgres-mail-data:

1.2 配置文件

创建 stalwart/config/config.toml


[server]

hostname = "mail.yourdomain.com"

http.port = 80

https.port = 443



[database]

url = "postgresql://mail_user:mail_password@postgres-mail:5432/mail_db"



[jmap]

enable = true

public-url = "https://jmap.yourdomain.com"



[smtp]

enable = true

port = 25

tls.implicit = false



[imap]

enable = true

port = 143

tls.implicit = false

1.3 初始化数据库

创建 init-scripts/01-init.sql


-- Stalwart 会自动创建所需的表结构

-- 这里可以添加一些初始数据

2. Actix-Web 后端集成

2.1 添加依赖

Cargo.toml 中添加:


[dependencies]

actix-web = "4.4"

serde = { version = "1.0", features = ["derive"] }

reqwest = { version = "0.11", features = ["json"] }

sqlx = { version = "0.7", features = ["postgres", "runtime-tokio-rustls"] }

tokio = { version = "1.0", features = ["full"] }

anyhow = "1.0"

2.2 JMAP 客户端实现

创建 src/mail/jmap_client.rs


use serde::{Deserialize, Serialize};

use anyhow::Result;



#[derive(Debug, Clone)]

pub struct JMAPClient {

    base_url: String,

    username: String,

    password: String,

    client: reqwest::Client,

}



#[derive(Debug, Serialize)]

pub struct JMAPRequest {

    #[serde(rename = "using")]

    using: Vec<String>,

    method_calls: Vec<Vec<serde_json::Value>>,

}



#[derive(Debug, Deserialize)]

pub struct JMAPResponse {

    method_responses: Vec<Vec<serde_json::Value>>,

    session_state: String,

}



#[derive(Debug, Serialize)]

pub struct Email {

    pub from: Option<String>,

    pub to: Option<Vec<String>>,

    pub subject: String,

    pub body: String,

    pub html_body: Option<String>,

}



impl JMAPClient {

    pub fn new(base_url: String, username: String, password: String) -> Self {

        Self {

            base_url,

            username,

            password,

            client: reqwest::Client::new(),

        }

    }



    pub async fn send_email(&self, email: Email) -> Result<String> {

        let request_body = JMAPRequest {

            using: vec![

                "urn:ietf:params:jmap:core".to_string(),

                "urn:ietf:params:jmap:mail".to_string(),

                "urn:ietf:params:jmap:submission".to_string(),

            ],

            method_calls: vec![

                vec![

                    "Email/send".into(),

                    {

                        let mut params = serde_json::Map::new();

                        params.insert("accountId".to_string(), self.get_account_id().await?.into());

                        params.insert("identityId".to_string(), self.get_identity_id().await?.into());

                        

                        let mut email_obj = serde_json::Map::new();

                        email_obj.insert("from".to_string(), serde_json::json!([{"email": email.from}]));

                        email_obj.insert("to".to_string(), serde_json::json!(email.to.unwrap_or_default().iter().map(|t| {"email": t}).collect::<Vec<_>>()));

                        email_obj.insert("subject".to_string(), email.subject.into());

                        

                        let mut body = serde_json::Map::new();

                        body.insert("type".to_string(), "text/plain".into());

                        body.insert("value".to_string(), email.body.into());

                        

                        email_obj.insert("body".to_string(), serde_json::json!([body]));

                        params.insert("email".to_string(), email_obj.into());

                        

                        params.into()

                    },

                    "0".into(),

                ],

            ],

        };



        let response = self.client

            .post(&format!("{}/jmap", self.base_url))

            .basic_auth(&self.username, Some(&self.password))

            .json(&request_body)

            .send()

            .await?;



        let jmap_response: JMAPResponse = response.json().await?;

        

        // 处理响应并返回邮件ID

        Ok("email_sent".to_string())

    }



    async fn get_account_id(&self) -> Result<String> {

        // 实现获取账户ID的逻辑

        Ok("account_id".to_string())

    }



    async fn get_identity_id(&self) -> Result<String> {

        // 实现获取身份ID的逻辑

        Ok("identity_id".to_string())

    }



    pub async fn get_emails(&self, limit: usize) -> Result<Vec<Email>> {

        // 实现获取邮件列表的逻辑

        Ok(vec![])

    }

}

2.3 邮件服务模块

创建 src/mail/mod.rs


pub mod jmap_client;



use serde::{Deserialize, Serialize};

use anyhow::Result;

use crate::mail::jmap_client::{JMAPClient, Email};



#[derive(Debug, Clone)]

pub struct MailService {

    jmap_client: JMAPClient,

}



#[derive(Debug, Serialize, Deserialize)]

pub struct SendEmailRequest {

    pub to: Vec<String>,

    pub subject: String,

    pub body: String,

    pub html_body: Option<String>,

}



impl MailService {

    pub fn new(jmap_url: String, username: String, password: String) -> Self {

        Self {

            jmap_client: JMAPClient::new(jmap_url, username, password),

        }

    }



    pub async fn send_email(&self, from: String, request: SendEmailRequest) -> Result<String> {

        let email = Email {

            from: Some(from),

            to: Some(request.to),

            subject: request.subject,

            body: request.body,

            html_body: request.html_body,

        };



        self.jmap_client.send_email(email).await

    }



    pub async fn get_emails(&self, limit: usize) -> Result<Vec<Email>> {

        self.jmap_client.get_emails(limit).await

    }

}

2.4 Actix-Web 路由

src/main.rs 或路由模块中:


use actix_web::{web, App, HttpServer, HttpResponse, Result};

use serde::{Deserialize, Serialize};

use crate::mail::{MailService, SendEmailRequest};



#[derive(Clone)]

pub struct AppState {

    pub mail_service: MailService,

}



#[derive(Debug, Deserialize)]

pub struct Pagination {

    pub page: Option<usize>,

    pub per_page: Option<usize>,

}



pub async fn send_email(

    data: web::Data<AppState>,

    request: web::Json<SendEmailRequest>,

) -> Result<HttpResponse> {

    // 从认证中获取发件人邮箱

    let from_email = "[email protected]".to_string();

    

    match data.mail_service.send_email(from_email, request.into_inner()).await {

        Ok(email_id) => Ok(HttpResponse::Ok().json(serde_json::json!({

            "success": true,

            "email_id": email_id

        }))),

        Err(e) => Ok(HttpResponse::InternalServerError().json(serde_json::json!({

            "success": false,

            "error": e.to_string()

        }))),

    }

}



pub async fn get_emails(

    data: web::Data<AppState>,

    pagination: web::Query<Pagination>,

) -> Result<HttpResponse> {

    let limit = pagination.per_page.unwrap_or(20);

    

    match data.mail_service.get_emails(limit).await {

        Ok(emails) => Ok(HttpResponse::Ok().json(serde_json::json!({

            "success": true,

            "emails": emails

        }))),

        Err(e) => Ok(HttpResponse::InternalServerError().json(serde_json::json!({

            "success": false,

            "error": e.to_string()

        }))),

    }

}



pub async fn get_email_stats(data: web::Data<AppState>) -> Result<HttpResponse> {

    // 实现获取邮件统计信息的逻辑

    // 可以直接查询 PostgreSQL 邮件数据库

    Ok(HttpResponse::Ok().json(serde_json::json!({

        "total_emails": 0,

        "unread_emails": 0

    })))

}

2.5 主应用配置


use actix_web::{web, App, HttpServer};

use sqlx::postgres::PgPoolOptions;



mod mail;



#[actix_web::main]

async fn main() -> std::io::Result<()> {

    // 初始化邮件服务

    let mail_service = mail::MailService::new(

        "http://stalwart-mail:80".to_string(),

        "[email protected]".to_string(),

        "password".to_string(),

    );



    let app_state = AppState {

        mail_service,

    };



    HttpServer::new(move || {

        App::new()

            .app_data(web::Data::new(app_state.clone()))

            .route("/api/emails/send", web::post().to(send_email))

            .route("/api/emails", web::get().to(get_emails))

            .route("/api/emails/stats", web::get().to(get_email_stats))

    })

    .bind("0.0.0.0:8080")?

    .run()

    .await

}

3. Yew 前端集成

3.1 邮件服务模块

创建 src/services/mail.rs


use serde::{Deserialize, Serialize};

use gloo_net::http::Request;

use wasm_bindgen_futures::spawn_local;

use yew::Callback;



#[derive(Debug, Clone, Serialize, Deserialize)]

pub struct Email {

    pub id: String,

    pub from: Option<String>,

    pub to: Option<Vec<String>>,

    pub subject: String,

    pub body: String,

    pub date: String,

}



#[derive(Debug, Clone, Serialize, Deserialize)]

pub struct SendEmailRequest {

    pub to: Vec<String>,

    pub subject: String,

    pub body: String,

    pub html_body: Option<String>,

}



#[derive(Debug, Clone, Serialize, Deserialize)]

pub struct ApiResponse<T> {

    pub success: bool,

    pub data: Option<T>,

    pub error: Option<String>,

}



pub struct MailService;



impl MailService {

    const BASE_URL: &'static str = "http://localhost:8080/api";



    pub async fn send_email(request: SendEmailRequest) -> Result<String, String> {

        let response = Request::post(&format!("{}/emails/send", Self::BASE_URL))

            .json(&request)

            .map_err(|e| e.to_string())?

            .send()

            .await

            .map_err(|e| e.to_string())?;



        if response.ok() {

            let api_response: ApiResponse<serde_json::Value> = response.json().await.map_err(|e| e.to_string())?;

            if api_response.success {

                Ok("Email sent successfully".to_string())

            } else {

                Err(api_response.error.unwrap_or_else(|| "Unknown error".to_string()))

            }

        } else {

            Err(format!("HTTP error: {}", response.status()))

        }

    }



    pub async fn get_emails(page: usize, per_page: usize) -> Result<Vec<Email>, String> {

        let response = Request::get(&format!("{}/emails?page={}&per_page={}", Self::BASE_URL, page, per_page))

            .send()

            .await

            .map_err(|e| e.to_string())?;



        if response.ok() {

            let api_response: ApiResponse<Vec<Email>> = response.json().await.map_err(|e| e.to_string())?;

            if api_response.success {

                Ok(api_response.data.unwrap_or_default())

            } else {

                Err(api_response.error.unwrap_or_else(|| "Unknown error".to_string()))

            }

        } else {

            Err(format!("HTTP error: {}", response.status()))

        }

    }

}

3.2 邮件组件

创建 src/components/mail_component.rs


use yew::prelude::*;

use crate::services::mail::{MailService, SendEmailRequest};



pub enum Msg {

    SetTo(String),

    SetSubject(String),

    SetBody(String),

    SendEmail,

    EmailSent(Result<String, String>),

}



pub struct MailComponent {

    to: String,

    subject: String,

    body: String,

    loading: bool,

    message: Option<String>,

}



impl Component for MailComponent {

    type Message = Msg;

    type Properties = ();



    fn create(_ctx: &Context<Self>) -> Self {

        Self {

            to: String::new(),

            subject: String::new(),

            body: String::new(),

            loading: false,

            message: None,

        }

    }



    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {

        match msg {

            Msg::SetTo(to) => {

                self.to = to;

                true

            }

            Msg::SetSubject(subject) => {

                self.subject = subject;

                true

            }

            Msg::SetBody(body) => {

                self.body = body;

                true

            }

            Msg::SendEmail => {

                self.loading = true;

                self.message = None;



                let request = SendEmailRequest {

                    to: self.to.split(',').map(|s| s.trim().to_string()).collect(),

                    subject: self.subject.clone(),

                    body: self.body.clone(),

                    html_body: None,

                };



                ctx.link().send_future(async move {

                    match MailService::send_email(request).await {

                        Ok(result) => Msg::EmailSent(Ok(result)),

                        Err(e) => Msg::EmailSent(Err(e)),

                    }

                });



                true

            }

            Msg::EmailSent(result) => {

                self.loading = false;

                self.message = Some(match result {

                    Ok(success) => format!("Success: {}", success),

                    Err(error) => format!("Error: {}", error),

                });



                // 清空表单

                if result.is_ok() {

                    self.to.clear();

                    self.subject.clear();

                    self.body.clear();

                }



                true

            }

        }

    }



    fn view(&self, ctx: &Context<Self>) -> Html {

        let on_to_input = ctx.link().callback(|e: InputEvent| {

            let input: web_sys::HtmlInputElement = e.target_unchecked_into();

            Msg::SetTo(input.value())

        });



        let on_subject_input = ctx.link().callback(|e: InputEvent| {

            let input: web_sys::HtmlInputElement = e.target_unchecked_into();

            Msg::SetSubject(input.value())

        });



        let on_body_input = ctx.link().callback(|e: InputEvent| {

            let input: web_sys::HtmlTextAreaElement = e.target_unchecked_into();

            Msg::SetBody(input.value())

        });



        let on_send_click = ctx.link().callback(|_| Msg::SendEmail);



        html! {

            <div class="mail-component">

                <h2>{"发送邮件"}</h2>

                

                <div class="form-group">

                    <label for="to">{"收件人 (多个用逗号分隔)"}</label>

                    <input

                        type="text"

                        id="to"

                        value={self.to.clone()}

                        oninput={on_to_input}

                        disabled={self.loading}

                    />

                </div>



                <div class="form-group">

                    <label for="subject">{"主题"}</label>

                    <input

                        type="text"

                        id="subject"

                        value={self.subject.clone()}

                        oninput={on_subject_input}

                        disabled={self.loading}

                    />

                </div>



                <div class="form-group">

                    <label for="body">{"正文"}</label>

                    <textarea

                        id="body"

                        rows="10"

                        value={self.body.clone()}

                        oninput={on_body_input}

                        disabled={self.loading}

                    />

                </div>



                <button 

                    onclick={on_send_click} 

                    disabled={self.loading || self.to.is_empty() || self.subject.is_empty()}

                >

                    { if self.loading { "发送中..." } else { "发送邮件" } }

                </button>



                {self.message.as_ref().map(|msg| html! {

                    <div class={if msg.contains("Success") { "success-message" } else { "error-message" }}>

                        {msg}

                    </div>

                })}

            </div>

        }

    }

}

4. 直接数据库查询(可选)

如果你需要直接查询邮件数据库来获取统计数据或执行复杂查询:


use sqlx::PgPool;



pub async fn get_email_analytics(pool: &PgPool) -> Result<serde_json::Value, sqlx::Error> {

    let total_emails: i64 = sqlx::query_scalar(

        "SELECT COUNT(*) FROM emails WHERE account_id = $1"

    )

    .bind("your_account_id")

    .fetch_one(pool)

    .await?;



    let unread_emails: i64 = sqlx::query_scalar(

        "SELECT COUNT(*) FROM emails WHERE account_id = $1 AND is_read = false"

    )

    .bind("your_account_id")

    .fetch_one(pool)

    .await?;



    Ok(serde_json::json!({

        "total_emails": total_emails,

        "unread_emails": unread_emails,

    }))

}

5. 部署和配置

5.1 环境变量配置

创建 .env 文件:


DATABASE_URL=postgresql://user:password@localhost:5432/your_app_db

JMAP_URL=http://stalwart-mail:80

[email protected]

JMAP_PASSWORD=your_password

5.2 完整的 docker-compose

创建完整的 docker-compose.prod.yml


version: '3.8'



services:

  stalwart-mail:

    # ... 同上



  postgres-mail:

    # ... 同上



  app-backend:

    build: ./backend

    ports:

      - "8080:8080"

    environment:

      - DATABASE_URL=postgresql://user:password@postgres-app:5432/app_db

      - JMAP_URL=http://stalwart-mail:80

    depends_on:

      - stalwart-mail

      - postgres-app



  postgres-app:

    image: postgres:15-alpine

    environment:

      - POSTGRES_DB=app_db

      - POSTGRES_USER=user

      - POSTGRES_PASSWORD=password

    volumes:

      - postgres-app-data:/var/lib/postgresql/data



  nginx:

    image: nginx:alpine

    ports:

      - "80:80"

      - "443:443"

    volumes:

      - ./nginx.conf:/etc/nginx/nginx.conf:ro

    depends_on:

      - app-backend



volumes:

  postgres-mail-data:

  postgres-app-data:

总结

通过以上配置,你可以:

  1. 发送邮件:通过 JMAP API 或 SMTP 发送邮件

  2. 接收邮件:Stalwart 自动处理接收的邮件并存储到 PostgreSQL

  3. 查询邮件:通过 JMAP API 或直接查询数据库获取邮件

  4. 管理邮件:通过 Web 界面管理邮件账户和设置

这种架构提供了高度的灵活性和可扩展性,同时保持了良好的性能。


本文由萧兮的博客原创发布,欢迎转载,转载务必保留原文链接。

萧兮的博客https://www.20010515.xyz · 原文:https://www.20010515.xyz/posts/b29abbd9-49fe-46f7-a519-1e682645ed92