This commit is contained in:
Ember Moth
2026-07-05 20:27:58 +08:00
commit 2744c70c5c
837 changed files with 53059 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "email"
version = "0.1.0"
edition = "2021"
[dependencies]
lettre = { version = "0.11", default-features = false, features = ["tokio1", "builder", "smtp-transport", "tokio1-native-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
tracing = "0.1"
thiserror = "2"
async-trait = "0.1"
anyhow = "1"
[dev-dependencies]
+11
View File
@@ -0,0 +1,11 @@
pub mod manager;
pub mod platform;
pub mod sender;
pub mod smtp;
pub mod template;
pub mod worker;
pub use manager::{get_global_manager, set_global_manager, WorkerManager};
pub use platform::{get_supported_platforms, Platform, PlatformInfo};
pub use sender::{new_sender, EmailError, Sender};
pub use worker::{ErrorInfo, Worker, WorkerStatus};
+121
View File
@@ -0,0 +1,121 @@
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use tokio::time::{sleep, Duration};
use crate::sender::Sender;
use crate::worker::{TaskInfo, Worker};
#[async_trait::async_trait]
pub trait TaskRepo: Send + Sync {
async fn find_one(&self, id: i64) -> Result<TaskInfo, anyhow::Error>;
async fn update(&self, data: &TaskInfo) -> Result<(), anyhow::Error>;
async fn update_status(&self, id: i64, status: i16) -> Result<(), anyhow::Error>;
fn is_cancelled(&self, id: i64) -> bool;
}
pub struct WorkerManager {
repo: Arc<dyn TaskRepo>,
sender: Arc<dyn Sender>,
workers: RwLock<HashMap<i64, WorkerHandle>>,
}
struct WorkerHandle {
worker: Arc<Worker>,
}
impl WorkerManager {
pub fn new(repo: Arc<dyn TaskRepo>, sender: Arc<dyn Sender>) -> Arc<Self> {
let manager = Arc::new(WorkerManager {
repo,
sender,
workers: RwLock::new(HashMap::new()),
});
let mgr = manager.clone();
tokio::spawn(async move {
loop {
sleep(Duration::from_secs(60)).await;
mgr.check_workers().await;
}
});
manager
}
pub async fn add_worker(&self, id: i64) {
let mut workers = self.workers.write().await;
if workers.contains_key(&id) {
tracing::info!(
"Batch Send Email: Worker already exists, task_id={}",
id
);
return;
}
let worker = Arc::new(Worker::new(id, self.repo.clone(), self.sender.clone()));
let handle = WorkerHandle {
worker: worker.clone(),
};
workers.insert(id, handle);
tracing::info!(
"Batch Send Email: Added new worker, task_id={}",
id
);
tokio::spawn(async move {
worker.start().await;
});
}
pub async fn get_worker(&self, id: i64) -> Option<Arc<Worker>> {
let workers = self.workers.read().await;
workers.get(&id).map(|h| h.worker.clone())
}
pub async fn remove_worker(&self, id: i64) {
let mut workers = self.workers.write().await;
if workers.remove(&id).is_some() {
tracing::info!(
"Batch Send Email: Removed worker, task_id={}",
id
);
} else {
tracing::error!(
"Batch Send Email: Worker not found for removal, task_id={}",
id
);
}
}
async fn check_workers(&self) {
let mut workers = self.workers.write().await;
let mut to_remove = Vec::new();
for (&id, handle) in workers.iter() {
if handle.worker.is_running().await as i16 == 2 {
to_remove.push(id);
}
}
for id in to_remove {
workers.remove(&id);
tracing::info!(
"Batch Send Email: Removed completed worker, task_id={}",
id
);
}
}
}
static MANAGER: std::sync::OnceLock<Arc<WorkerManager>> = std::sync::OnceLock::new();
pub fn set_global_manager(manager: Arc<WorkerManager>) -> Result<(), Arc<WorkerManager>> {
MANAGER.set(manager)
}
pub fn get_global_manager() -> Option<&'static Arc<WorkerManager>> {
MANAGER.get()
}
+54
View File
@@ -0,0 +1,54 @@
use std::collections::HashMap;
use std::str::FromStr;
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Platform {
Smtp,
Unsupported,
}
impl FromStr for Platform {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"smtp" => Ok(Platform::Smtp),
_ => Ok(Platform::Unsupported),
}
}
}
impl Platform {
pub fn as_str(&self) -> &'static str {
match self {
Platform::Smtp => "smtp",
Platform::Unsupported => "unsupported",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct PlatformInfo {
pub platform: String,
pub platform_url: String,
pub platform_field_description: HashMap<String, String>,
}
pub fn get_supported_platforms() -> Vec<PlatformInfo> {
let mut desc = HashMap::new();
desc.insert("host".into(), "host".into());
desc.insert("port".into(), "port".into());
desc.insert("user".into(), "user".into());
desc.insert("pass".into(), "pass".into());
desc.insert("from".into(), "from".into());
desc.insert("reply_to".into(), "reply_to".into());
desc.insert("ssl".into(), "ssl".into());
vec![PlatformInfo {
platform: "smtp".into(),
platform_url: String::new(),
platform_field_description: desc,
}]
}
+36
View File
@@ -0,0 +1,36 @@
use std::str::FromStr;
use crate::platform::Platform;
use crate::smtp::{SmtpClient, SmtpConfig};
#[derive(Debug, thiserror::Error)]
pub enum EmailError {
#[error("SMTP transport error: {0}")]
SmtpTransport(#[from] lettre::transport::smtp::Error),
#[error("Message build error: {0}")]
MessageBuild(String),
#[error("Unsupported platform: {0}")]
UnsupportedPlatform(String),
#[error("Config parse error: {0}")]
ConfigParse(#[from] serde_json::Error),
}
#[async_trait::async_trait]
pub trait Sender: Send + Sync {
async fn send(&self, to: &[String], subject: &str, body: &str) -> Result<(), EmailError>;
}
pub fn new_sender(
platform: &str,
config: &str,
site_name: &str,
) -> Result<Box<dyn Sender>, EmailError> {
match Platform::from_str(platform).unwrap_or(Platform::Unsupported) {
Platform::Smtp => {
let mut cfg: SmtpConfig = serde_json::from_str(config)?;
cfg.site_name = site_name.to_string();
Ok(Box::new(SmtpClient::new(cfg)))
}
_ => Err(EmailError::UnsupportedPlatform(platform.to_string())),
}
}
+97
View File
@@ -0,0 +1,97 @@
use lettre::message::header::ContentType;
use lettre::transport::smtp::authentication::Credentials;
use lettre::transport::smtp::client::{Tls, TlsParameters};
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
use serde::Deserialize;
use crate::sender::EmailError;
#[derive(Debug, Clone, Deserialize)]
pub struct SmtpConfig {
pub host: String,
pub port: u16,
pub user: String,
pub pass: String,
pub from: String,
pub reply_to: Option<String>,
pub ssl: bool,
#[serde(default)]
pub site_name: String,
}
pub struct SmtpClient {
config: SmtpConfig,
mailer: AsyncSmtpTransport<Tokio1Executor>,
}
impl SmtpClient {
pub fn new(config: SmtpConfig) -> Self {
let creds = Credentials::new(config.user.clone(), config.pass.clone());
let tls_params = TlsParameters::new(config.host.clone())
.expect("failed to build TLS parameters");
let tls = if config.ssl {
Tls::Wrapper(tls_params)
} else {
Tls::Required(tls_params)
};
let mailer = AsyncSmtpTransport::<Tokio1Executor>::relay(&config.host)
.expect("failed to build SMTP relay")
.port(config.port)
.credentials(creds)
.tls(tls)
.build();
SmtpClient { config, mailer }
}
}
#[async_trait::async_trait]
impl crate::sender::Sender for SmtpClient {
async fn send(&self, to: &[String], subject: &str, body: &str) -> Result<(), EmailError> {
let site_name = if self.config.site_name.is_empty() {
self.config.from.clone()
} else {
self.config.site_name.clone()
};
let from_header = format!("{} <{}>", site_name, self.config.from);
let from_addr: lettre::message::Mailbox = from_header
.parse()
.map_err(|e: lettre::address::AddressError| {
EmailError::MessageBuild(e.to_string())
})?;
let mut builder = Message::builder().from(from_addr);
if let Some(ref reply_to) = self.config.reply_to {
let reply_addr: lettre::message::Mailbox = reply_to
.parse()
.map_err(|e: lettre::address::AddressError| {
EmailError::MessageBuild(e.to_string())
})?;
builder = builder.reply_to(reply_addr);
}
for addr in to {
let to_addr: lettre::message::Mailbox = addr
.parse()
.map_err(|e: lettre::address::AddressError| {
EmailError::MessageBuild(e.to_string())
})?;
builder = builder.to(to_addr);
}
let message = builder
.subject(subject)
.header(ContentType::TEXT_HTML)
.body(body.to_string())
.map_err(|e| EmailError::MessageBuild(e.to_string()))?;
self.mailer.send(message).await?;
Ok(())
}
}
+175
View File
@@ -0,0 +1,175 @@
pub const DEFAULT_EMAIL_VERIFY_TEMPLATE: &str = r#"<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>
{{if eq .Type 1}}注册验证码 / Registration Verification Code{{else}}重置密码验证码 / Password
Reset Verification Code{{end}}
</title>
<style>
body { color: black; }
.container { border-radius: 5px; width: 500px; margin: 20px auto 0; border: 1px solid #cce7ff; background-color: #f0f8ff; padding: 25px 30px; }
.header { text-align: center; display: flex; align-items: center; justify-content: center; }
.logo { width: 56px; height: 56px; object-fit: cover; margin-right: 10px; }
.site-name { font-size: 18px; font-weight: bold; margin: 0; }
.content { margin: 10px 0; font-size: 14px; }
.greeting { font-weight: 700; margin: 5px 0; }
.highlight { margin: 0 2px; font-weight: 700; color: #007bff; }
.code-container { margin: 25px 0; width: 100%; background-color: #e6f2ff; height: 60px; line-height: 60px; text-align: center; font-size: 32px; font-weight: 700; color: #007bff; }
.code { letter-spacing: 5pt; }
.footer { border-top: #99ccff 1px solid; margin-top: 20px; padding-top: 5px; font-size: 12px; font-weight: 700; color: #777; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="{{.SiteLogo}}" class="logo" />
<p class="site-name">{{.SiteName}}</p>
</div>
<div class="content">
<p class="greeting">Hi, 尊敬的用户 / Dear User</p>
<p>
{{if eq .Type 1}} 感谢您注册!您的验证码是(请于<span class="highlight">{{.Expire}}</span>分钟内使用):
<br />
Thank you for registering! Your verification code is (please use it within <span class="highlight">{{.Expire}}</span> minutes): {{else}}
您正在重置密码。您的验证码是(请于<span class="highlight">{{.Expire}}</span>分钟内使用):
<br />
You are resetting your password. Your verification code is (please use it within <span class="highlight">{{.Expire}}</span> minutes): {{end}}
</p>
<div class="code-container">
<span class="code">{{.Code}}</span>
</div>
<p>
如果您未请求此验证码,请忽略此邮件。<br />If you did not request this code, please ignore this email.
</p>
</div>
<div class="footer">此为系统邮件,请勿回复 / This is a system email, please do not reply</div>
</div>
</body>
</html>"#;
pub const DEFAULT_MAINTENANCE_EMAIL_TEMPLATE: &str = r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>系统维护通知 / System Maintenance Notice</title>
<style>
body { color: black; }
.container { border-radius: 5px; width: 500px; margin: 20px auto 0; border: 1px solid #cce7ff; background-color: #f0f8ff; padding: 25px 30px; }
.header { text-align: center; display: flex; align-items: center; justify-content: center; }
.logo { width: 56px; height: 56px; object-fit: cover; margin-right: 10px; }
.site-name { font-size: 18px; font-weight: bold; margin: 0; }
.content { margin: 20px 0; font-size: 14px; }
.greeting { font-weight: 700; margin: 5px 0; }
.highlight { margin: 0 2px; font-weight: 700; color: #007bff; }
.footer { border-top: #99ccff 1px solid; margin-top: 20px; padding-top: 5px; font-size: 12px; font-weight: 700; color: #777; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="{{.SiteLogo}}" class="logo" />
<p class="site-name">{{.SiteName}}</p>
</div>
<div class="content">
<p class="greeting">Hi, 尊敬的用户 / Dear User</p>
<p>
我们计划在<span class="highlight">{{.MaintenanceDate}}</span>进行系统维护,预计维护时间为<span class="highlight">{{.MaintenanceTime}}</span>。在此期间,您可能会遇到服务中断或无法访问的情况。
<br />
We will be performing system maintenance on <span class="highlight">{{.MaintenanceDate}}</span>, and the expected maintenance period is <span class="highlight">{{.MaintenanceTime}}</span>. During this time, you may experience service interruptions or unavailability.
</p>
<p>
维护完成后,系统将自动恢复。如果您有任何问题,请随时联系我们的支持团队。
<br />
The system will resume automatically once the maintenance is completed. If you have any questions, please feel free to contact our support team.
</p>
</div>
<div class="footer">此为系统邮件,请勿回复 / This is a system email, please do not reply</div>
</div>
</body>
</html>"#;
pub const DEFAULT_EXPIRATION_EMAIL_TEMPLATE: &str = r#"<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>服务到期通知 / Service Expiration Notice</title>
<style>
body { color: black; }
.container { border-radius: 5px; width: 500px; margin: 20px auto 0; border: 1px solid #cce7ff; background-color: #f0f8ff; padding: 25px 30px; }
.header { text-align: center; display: flex; align-items: center; justify-content: center; }
.logo { width: 56px; height: 56px; object-fit: cover; margin-right: 10px; }
.site-name { font-size: 18px; font-weight: bold; margin: 0; }
.content { margin: 20px 0; font-size: 14px; }
.greeting { font-weight: 700; margin: 5px 0; }
.highlight { margin: 0 2px; font-weight: 700; color: #007bff; }
.footer { border-top: #99ccff 1px solid; margin-top: 20px; padding-top: 5px; font-size: 12px; font-weight: 700; color: #777; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="{{.SiteLogo}}" class="logo" />
<p class="site-name">{{.SiteName}}</p>
</div>
<div class="content">
<p class="greeting">Hi, 尊敬的用户 / Dear User</p>
<p>
您的服务即将在<span class="highlight">{{.ExpireDate}}</span>到期,请及时续费以保证服务不间断。
<br />
Your service is set to expire on <span class="highlight">{{.ExpireDate}}</span>. Please renew your subscription to avoid service interruptions.
</p>
<p>
如需帮助,请联系客服团队。感谢您的支持!
<br />
If you need assistance, please contact our support team. Thank you for your continued support!
</p>
</div>
<div class="footer">此为系统邮件,请勿回复 / This is a system email, please do not reply</div>
</div>
</body>
</html>"#;
pub const DEFAULT_TRAFFIC_EXCEED_EMAIL_TEMPLATE: &str = r#"<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>流量用尽通知 / Traffic Exhausted Notice</title>
<style>
.container { border-radius: 5px; width: 500px; margin: 20px auto 0; border: 1px solid #cce7ff; background-color: #f0f8ff; padding: 25px 30px; }
.header { text-align: center; display: flex; align-items: center; justify-content: center; }
.logo { width: 56px; height: 56px; object-fit: cover; margin-right: 10px; }
.site-name { font-size: 18px; font-weight: bold; margin: 0; }
.content { margin: 20px 0; font-size: 14px; }
.greeting { font-weight: 700; margin: 5px 0; }
.highlight { color: #007bff; }
.footer { border-top: #99ccff 1px solid; margin-top: 20px; padding-top: 5px; font-size: 12px; font-weight: 700; color: #777; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="{{.SiteLogo}}" class="logo" />
<p class="site-name">{{.SiteName}}</p>
</div>
<div class="content">
<p class="greeting">Hi, 尊敬的用户 / Dear User</p>
<p>
您的流量已经用尽,请及时购买流量以继续使用我们的服务。
<br />
Your traffic has been exhausted. Please purchase additional traffic to continue using our service.
</p>
<p>
如需帮助,请联系客服团队。感谢您的支持!
<br />
If you need assistance, please contact our support team. Thank you for your continued support!
</p>
</div>
<div class="footer">此为系统邮件,请勿回复 / This is a system email, please do not reply</div>
</div>
</body>
</html>"#;
+263
View File
@@ -0,0 +1,263 @@
use std::sync::Arc;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use tokio::time::sleep;
use crate::sender::Sender;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorInfo {
pub error: String,
pub email: String,
pub time: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkerStatus {
Idle = 0,
Running = 1,
Completed = 2,
}
#[derive(Debug, Clone, Deserialize)]
pub struct EmailScope {
#[serde(rename = "type")]
pub type_: i16,
#[serde(default)]
pub register_start_time: i64,
#[serde(default)]
pub register_end_time: i64,
#[serde(default)]
pub recipients: Vec<String>,
#[serde(default)]
pub additional: Vec<String>,
#[serde(default)]
pub scheduled: i64,
#[serde(default)]
pub interval: i16,
#[serde(default)]
pub limit: i64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct EmailContent {
pub subject: String,
pub content: String,
}
#[derive(Debug, Clone)]
pub struct TaskInfo {
pub id: i64,
pub type_: i16,
pub scope: String,
pub content: String,
pub status: i16,
pub errors: String,
pub total: i64,
pub current: i64,
}
pub struct Worker {
id: i64,
repo: Arc<dyn crate::manager::TaskRepo>,
sender: Arc<dyn Sender>,
status: Arc<Mutex<WorkerStatus>>,
}
impl Worker {
pub fn new(
id: i64,
repo: Arc<dyn crate::manager::TaskRepo>,
sender: Arc<dyn Sender>,
) -> Self {
Worker {
id,
repo,
sender,
status: Arc::new(Mutex::new(WorkerStatus::Idle)),
}
}
pub fn id(&self) -> i64 {
self.id
}
pub async fn is_running(&self) -> WorkerStatus {
*self.status.lock().await
}
pub async fn start(&self) {
let task_info = match self.repo.find_one(self.id).await {
Ok(t) => t,
Err(e) => {
tracing::error!(
"Batch Send Email: Failed to find task, task_id={}, error={}",
self.id,
e
);
return;
}
};
if task_info.status != 0 {
tracing::error!(
"Batch Send Email: Task already completed or in progress, task_id={}",
self.id
);
return;
}
let scope: EmailScope = match serde_json::from_str(&task_info.scope) {
Ok(s) => s,
Err(e) => {
tracing::error!(
"Batch Send Email: Failed to parse task scope, task_id={}, error={}",
self.id,
e
);
return;
}
};
if scope.recipients.is_empty() && scope.additional.is_empty() {
tracing::error!(
"Batch Send Email: No recipients or additional emails provided, task_id={}",
self.id
);
return;
}
let content: EmailContent = match serde_json::from_str(&task_info.content) {
Ok(c) => c,
Err(e) => {
tracing::error!(
"Batch Send Email: Failed to parse task content, task_id={}, error={}",
self.id,
e
);
return;
}
};
{
let mut status = self.status.lock().await;
*status = WorkerStatus::Running;
}
let mut recipients = scope.recipients.clone();
recipients.extend(scope.additional.clone());
remove_duplicates_and_empty(&mut recipients);
if recipients.is_empty() {
tracing::error!(
"Batch Send Email: No valid recipients found, task_id={}",
self.id
);
let mut status = self.status.lock().await;
*status = WorkerStatus::Completed;
return;
}
let interval = if scope.interval == 0 {
Duration::from_secs(1)
} else {
Duration::from_secs(scope.interval as u64)
};
let mut errors: Vec<ErrorInfo> = Vec::new();
let mut count: i64 = 0;
for recipient in &recipients {
if self.repo.is_cancelled(self.id) {
tracing::info!(
"Batch Send Email: Worker stopped by cancellation, task_id={}",
self.id
);
return;
}
if task_info.status == 0 {
// mark as in-progress via repo
let _ = self.repo.update_status(self.id, 1).await;
}
if let Err(e) = self
.sender
.send(std::slice::from_ref(recipient), &content.subject, &content.content)
.await
{
tracing::error!(
"Batch Send Email: Failed to send email, task_id={}, recipient={}, error={}",
self.id,
recipient,
e
);
errors.push(ErrorInfo {
error: e.to_string(),
email: recipient.clone(),
time: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64,
});
}
count += 1;
let mut updated = task_info.clone();
updated.current = count;
updated.errors = serde_json::to_string(&errors).unwrap_or_default();
if let Err(e) = self.repo.update(&updated).await {
tracing::error!(
"Batch Send Email: Failed to update task progress, task_id={}, error={}",
self.id,
e
);
let mut status = self.status.lock().await;
*status = WorkerStatus::Completed;
}
sleep(interval).await;
}
let mut status = self.status.lock().await;
*status = WorkerStatus::Completed;
let mut finalized = task_info.clone();
finalized.status = 2;
finalized.current = count;
finalized.errors = serde_json::to_string(&errors).unwrap_or_default();
match self.repo.update(&finalized).await {
Ok(_) => {
tracing::info!(
"Batch Send Email: Task completed successfully, task_id={}, total_sent={}",
self.id,
count
);
}
Err(e) => {
tracing::error!(
"Batch Send Email: Failed to finalize task, task_id={}, error={}",
self.id,
e
);
}
}
}
}
fn remove_duplicates_and_empty(items: &mut Vec<String>) {
let mut seen = std::collections::HashSet::new();
items.retain(|item| {
if item.is_empty() || seen.contains(item) {
false
} else {
seen.insert(item.clone());
true
}
});
}