mirror of
https://github.com/perfect-panel/ppanel-web.git
synced 2026-08-29 05:52:08 -04:00
Initial
This commit is contained in:
@@ -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]
|
||||
@@ -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};
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
}]
|
||||
}
|
||||
@@ -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())),
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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>"#;
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user