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:
总结
通过以上配置,你可以:
-
发送邮件:通过 JMAP API 或 SMTP 发送邮件
-
接收邮件:Stalwart 自动处理接收的邮件并存储到 PostgreSQL
-
查询邮件:通过 JMAP API 或直接查询数据库获取邮件
-
管理邮件:通过 Web 界面管理邮件账户和设置
这种架构提供了高度的灵活性和可扩展性,同时保持了良好的性能。
本文由萧兮的博客原创发布,欢迎转载,转载务必保留原文链接。
萧兮的博客:https://www.20010515.xyz · 原文:https://www.20010515.xyz/posts/b29abbd9-49fe-46f7-a519-1e682645ed92