chore(project): initialize project with core configuration and dependencies
- Add .gitignore and .env.example files for project setup - Create build script for proto compilation with tonic-prost - Generate Cargo.lock with all project dependencies - Configure project structure and ignore patterns for development environment
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
# SMTP server
|
||||||
|
APP_SMTP_HOST=smtp.example.com
|
||||||
|
APP_SMTP_PORT=587
|
||||||
|
APP_SMTP_USERNAME=
|
||||||
|
APP_SMTP_PASSWORD=
|
||||||
|
APP_SMTP_FROM_EMAIL=noreply@example.com
|
||||||
|
APP_SMTP_FROM_NAME=EmailKS
|
||||||
|
APP_SMTP_REPLY_TO=
|
||||||
|
# one of: none | starttls | tls
|
||||||
|
APP_SMTP_TLS=starttls
|
||||||
|
APP_SMTP_TIMEOUT_SECS=30
|
||||||
|
APP_SMTP_HELO_NAME=
|
||||||
|
# Allow per-request From override (disabled by default)
|
||||||
|
APP_SMTP_ALLOW_REQUEST_FROM=false
|
||||||
|
|
||||||
|
# RPC listen address (defaults to loopback)
|
||||||
|
APP_SMTP_LISTEN_ADDR=127.0.0.1:50051
|
||||||
|
|
||||||
|
# Optional queue capacity (bounded to 1000 if unset; set 0 for unbounded)
|
||||||
|
APP_SMTP_QUEUE_CAPACITY=
|
||||||
|
|
||||||
|
# Log level (trace|debug|info|warn|error)
|
||||||
|
RUST_LOG=info
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/target
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.idea/
|
||||||
Generated
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# 默认忽略的文件
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# 基于编辑器的 HTTP 客户端请求
|
||||||
|
/httpRequests/
|
||||||
|
# 已忽略包含查询文件的默认文件夹
|
||||||
|
/queries/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
Generated
+1753
File diff suppressed because it is too large
Load Diff
+27
@@ -0,0 +1,27 @@
|
|||||||
|
[package]
|
||||||
|
name = "emailks"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "emailks"
|
||||||
|
path = "lib.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "emailks"
|
||||||
|
path = "main.rs"
|
||||||
|
[dependencies]
|
||||||
|
dotenvy = "0.15"
|
||||||
|
lettre = { version = "0.11", features = ["tokio1-native-tls"] }
|
||||||
|
prost = "0.14"
|
||||||
|
prost-types = "0.14"
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal"] }
|
||||||
|
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||||
|
tonic = "0.14"
|
||||||
|
tonic-health = "0.14"
|
||||||
|
tonic-prost = "0.14"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tonic-prost-build = "0.14"
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
use std::{env, path::PathBuf};
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
println!("cargo:rerun-if-changed=proto/email.proto");
|
||||||
|
println!("cargo:rerun-if-changed=proto");
|
||||||
|
|
||||||
|
let out_dir = PathBuf::from(env::var("OUT_DIR")?).join("pb");
|
||||||
|
std::fs::create_dir_all(&out_dir)?;
|
||||||
|
|
||||||
|
tonic_prost_build::configure()
|
||||||
|
.out_dir(&out_dir)
|
||||||
|
.compile_protos(&["proto/email.proto"], &["proto"])?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
use std::{env, fmt, net::SocketAddr, time::Duration};
|
||||||
|
|
||||||
|
pub use crate::error::ConfigError;
|
||||||
|
use tracing;
|
||||||
|
|
||||||
|
const ENV_PREFIX: &str = "APP_SMTP_";
|
||||||
|
const DEFAULT_LISTEN_ADDR: &str = "127.0.0.1:50051";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub smtp: SmtpConfig,
|
||||||
|
pub queue_capacity: Option<usize>,
|
||||||
|
pub listen_addr: SocketAddr,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
|
pub struct SmtpConfig {
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub password: Option<String>,
|
||||||
|
pub from_email: Option<String>,
|
||||||
|
pub from_name: Option<String>,
|
||||||
|
pub reply_to: Option<String>,
|
||||||
|
pub tls: SmtpTls,
|
||||||
|
pub timeout: Duration,
|
||||||
|
pub helo_name: Option<String>,
|
||||||
|
pub allow_request_from: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for SmtpConfig {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("SmtpConfig")
|
||||||
|
.field("host", &self.host)
|
||||||
|
.field("port", &self.port)
|
||||||
|
.field("username", &self.username)
|
||||||
|
.field("password", &self.password.as_ref().map(|_| "***"))
|
||||||
|
.field("from_email", &self.from_email)
|
||||||
|
.field("from_name", &self.from_name)
|
||||||
|
.field("reply_to", &self.reply_to)
|
||||||
|
.field("tls", &self.tls)
|
||||||
|
.field("timeout", &self.timeout)
|
||||||
|
.field("helo_name", &self.helo_name)
|
||||||
|
.field("allow_request_from", &self.allow_request_from)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SmtpTls {
|
||||||
|
None,
|
||||||
|
#[default]
|
||||||
|
StartTls,
|
||||||
|
Tls,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppConfig {
|
||||||
|
pub fn from_env() -> Result<Self, ConfigError> {
|
||||||
|
let _ = dotenvy::dotenv();
|
||||||
|
|
||||||
|
let queue_capacity: Option<usize> = optional("QUEUE_CAPACITY")?
|
||||||
|
.map(|v| {
|
||||||
|
v.parse::<usize>()
|
||||||
|
.map_err(|e| invalid("QUEUE_CAPACITY", e.to_string()))
|
||||||
|
})
|
||||||
|
.transpose()?;
|
||||||
|
|
||||||
|
let listen_addr = parse_or(
|
||||||
|
"LISTEN_ADDR",
|
||||||
|
DEFAULT_LISTEN_ADDR.parse().expect("valid default address"),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
smtp: SmtpConfig::from_env()?,
|
||||||
|
queue_capacity,
|
||||||
|
listen_addr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SmtpConfig {
|
||||||
|
pub fn from_env() -> Result<Self, ConfigError> {
|
||||||
|
let _ = dotenvy::dotenv();
|
||||||
|
|
||||||
|
let host = required("HOST")?;
|
||||||
|
let port = parse_or("PORT", 587)?;
|
||||||
|
let from_email = optional("FROM_EMAIL")?;
|
||||||
|
let reply_to = optional("REPLY_TO")?;
|
||||||
|
let timeout_secs = parse_or("TIMEOUT_SECS", 30)?;
|
||||||
|
let allow_request_from = parse_bool("ALLOW_REQUEST_FROM", false)?;
|
||||||
|
|
||||||
|
validate_port("PORT", port)?;
|
||||||
|
validate_email_if_present("FROM_EMAIL", from_email.as_deref())?;
|
||||||
|
validate_email_if_present("REPLY_TO", reply_to.as_deref())?;
|
||||||
|
|
||||||
|
let tls = parse_tls("TLS")?;
|
||||||
|
if matches!(tls, SmtpTls::None) {
|
||||||
|
tracing::warn!(
|
||||||
|
"SMTP TLS is disabled — credentials and email content will be sent in plaintext"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
username: optional("USERNAME")?,
|
||||||
|
password: optional("PASSWORD")?,
|
||||||
|
from_email,
|
||||||
|
from_name: optional("FROM_NAME")?,
|
||||||
|
reply_to,
|
||||||
|
tls,
|
||||||
|
timeout: Duration::from_secs(timeout_secs),
|
||||||
|
helo_name: optional("HELO_NAME")?,
|
||||||
|
allow_request_from,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_credentials(&self) -> bool {
|
||||||
|
self.username.as_deref().is_some_and(|v| !v.is_empty())
|
||||||
|
&& self.password.as_deref().is_some_and(|v| !v.is_empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for SmtpTls {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::None => f.write_str("none"),
|
||||||
|
Self::StartTls => f.write_str("starttls"),
|
||||||
|
Self::Tls => f.write_str("tls"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required(name: &'static str) -> Result<String, ConfigError> {
|
||||||
|
match optional(name)? {
|
||||||
|
Some(value) => Ok(value),
|
||||||
|
None => Err(ConfigError::MissingEnv { name }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional(name: &'static str) -> Result<Option<String>, ConfigError> {
|
||||||
|
match env::var(env_name(name)) {
|
||||||
|
Ok(value) if value.trim().is_empty() => Ok(None),
|
||||||
|
Ok(value) => Ok(Some(value)),
|
||||||
|
Err(env::VarError::NotPresent) => Ok(None),
|
||||||
|
Err(env::VarError::NotUnicode(_)) => Err(invalid(name, "value is not valid UTF-8")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_or<T>(name: &'static str, default: T) -> Result<T, ConfigError>
|
||||||
|
where
|
||||||
|
T: std::str::FromStr,
|
||||||
|
T::Err: fmt::Display,
|
||||||
|
{
|
||||||
|
optional(name)?
|
||||||
|
.map(|value| {
|
||||||
|
value
|
||||||
|
.parse::<T>()
|
||||||
|
.map_err(|err| invalid(name, err.to_string()))
|
||||||
|
})
|
||||||
|
.unwrap_or(Ok(default))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_tls(name: &'static str) -> Result<SmtpTls, ConfigError> {
|
||||||
|
let Some(value) = optional(name)? else {
|
||||||
|
return Ok(SmtpTls::default());
|
||||||
|
};
|
||||||
|
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"none" | "false" | "0" => Ok(SmtpTls::None),
|
||||||
|
"starttls" | "start_tls" | "start-tls" => Ok(SmtpTls::StartTls),
|
||||||
|
"tls" | "ssl" | "smtps" => Ok(SmtpTls::Tls),
|
||||||
|
_ => Err(invalid(name, "expected one of: none, starttls, tls")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_bool(name: &'static str, default: bool) -> Result<bool, ConfigError> {
|
||||||
|
let Some(value) = optional(name)? else {
|
||||||
|
return Ok(default);
|
||||||
|
};
|
||||||
|
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"true" | "1" | "yes" | "y" | "on" => Ok(true),
|
||||||
|
"false" | "0" | "no" | "n" | "off" => Ok(false),
|
||||||
|
_ => Err(invalid(name, "expected a boolean value")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_port(name: &'static str, port: u16) -> Result<(), ConfigError> {
|
||||||
|
if port == 0 {
|
||||||
|
return Err(invalid(name, "must be between 1 and 65535"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_email_if_present(name: &'static str, value: Option<&str>) -> Result<(), ConfigError> {
|
||||||
|
if let Some(value) = value
|
||||||
|
&& !(value.contains('@') && value.split('@').all(|part| !part.is_empty()))
|
||||||
|
{
|
||||||
|
return Err(invalid(name, "must be a valid email address"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env_name(name: &str) -> String {
|
||||||
|
format!("{ENV_PREFIX}{name}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid(name: &'static str, reason: impl Into<String>) -> ConfigError {
|
||||||
|
ConfigError::InvalidEnv {
|
||||||
|
name,
|
||||||
|
reason: reason.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
|
||||||
|
|
||||||
|
pub use crate::error::EmailError;
|
||||||
|
use crate::{
|
||||||
|
config::SmtpConfig, email_build::build_message_from_parts, pb::email::v1::SendEmailRequest,
|
||||||
|
queue::EmailJob,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) type Mailer = AsyncSmtpTransport<Tokio1Executor>;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct EmailSender {
|
||||||
|
config: Arc<SmtpConfig>,
|
||||||
|
mailer: Mailer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EmailSender {
|
||||||
|
pub fn new(config: SmtpConfig) -> Result<Self, EmailError> {
|
||||||
|
let mailer = crate::email_build::build_mailer(&config)?;
|
||||||
|
Ok(Self {
|
||||||
|
config: Arc::new(config),
|
||||||
|
mailer,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send(&self, request: &SendEmailRequest) -> Result<(), EmailError> {
|
||||||
|
let message = build_message_from_parts(&self.config, request)?;
|
||||||
|
self.mailer
|
||||||
|
.send(message)
|
||||||
|
.await
|
||||||
|
.map_err(|e| EmailError::Send(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_job(&self, job: &EmailJob) -> Result<(), EmailError> {
|
||||||
|
self.send(&job.request).await
|
||||||
|
}
|
||||||
|
}
|
||||||
+288
@@ -0,0 +1,288 @@
|
|||||||
|
use lettre::{
|
||||||
|
Address, AsyncSmtpTransport, Message, Tokio1Executor,
|
||||||
|
message::{
|
||||||
|
Attachment as LettreAttachment, Body, Mailbox, MultiPart, SinglePart,
|
||||||
|
header::{ContentType, HeaderName, HeaderValue},
|
||||||
|
},
|
||||||
|
transport::smtp::{self, authentication::Credentials, extension::ClientId},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
config::{SmtpConfig, SmtpTls},
|
||||||
|
error::EmailError,
|
||||||
|
pb::email::v1::{Attachment, EmailAddress, EmailPriority, SendEmailRequest},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) fn build_mailer(
|
||||||
|
config: &SmtpConfig,
|
||||||
|
) -> Result<AsyncSmtpTransport<Tokio1Executor>, EmailError> {
|
||||||
|
let mut builder = match config.tls {
|
||||||
|
SmtpTls::None => {
|
||||||
|
smtp::AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&config.host)
|
||||||
|
}
|
||||||
|
SmtpTls::StartTls => {
|
||||||
|
smtp::AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&config.host)
|
||||||
|
.map_err(|e| EmailError::BuildTransport(e.to_string()))?
|
||||||
|
}
|
||||||
|
SmtpTls::Tls => smtp::AsyncSmtpTransport::<Tokio1Executor>::relay(&config.host)
|
||||||
|
.map_err(|e| EmailError::BuildTransport(e.to_string()))?,
|
||||||
|
}
|
||||||
|
.port(config.port)
|
||||||
|
.timeout(Some(config.timeout));
|
||||||
|
|
||||||
|
match (&config.username, &config.password) {
|
||||||
|
(Some(u), Some(p)) => {
|
||||||
|
builder = builder.credentials(Credentials::new(u.clone(), p.clone()));
|
||||||
|
}
|
||||||
|
(None, None) => {}
|
||||||
|
_ => return Err(EmailError::IncompleteCredentials),
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(name) = config.helo_name.as_deref().and_then(non_empty) {
|
||||||
|
builder = builder.hello_name(ClientId::Domain(name.to_owned()));
|
||||||
|
}
|
||||||
|
Ok(builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_body(
|
||||||
|
builder: lettre::message::MessageBuilder,
|
||||||
|
request: &SendEmailRequest,
|
||||||
|
) -> Result<Message, EmailError> {
|
||||||
|
match select_body_multipart(request) {
|
||||||
|
BodyKind::Both(mp) => builder
|
||||||
|
.multipart(mp)
|
||||||
|
.map_err(|e| EmailError::BuildMessage(e.to_string())),
|
||||||
|
BodyKind::Html(sp) => builder
|
||||||
|
.singlepart(sp)
|
||||||
|
.map_err(|e| EmailError::BuildMessage(e.to_string())),
|
||||||
|
BodyKind::Plain(sp) => builder
|
||||||
|
.singlepart(sp)
|
||||||
|
.map_err(|e| EmailError::BuildMessage(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_mixed_body(request: &SendEmailRequest) -> MultiPart {
|
||||||
|
let mixed = MultiPart::mixed();
|
||||||
|
match select_body_multipart(request) {
|
||||||
|
BodyKind::Both(mp) => mixed.multipart(mp),
|
||||||
|
BodyKind::Html(sp) => mixed.singlepart(sp),
|
||||||
|
BodyKind::Plain(sp) => mixed.singlepart(sp),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BodyKind {
|
||||||
|
Both(MultiPart),
|
||||||
|
Html(SinglePart),
|
||||||
|
Plain(SinglePart),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_body_multipart(request: &SendEmailRequest) -> BodyKind {
|
||||||
|
if !request.text_body.is_empty() && !request.html_body.is_empty() {
|
||||||
|
BodyKind::Both(MultiPart::alternative_plain_html(
|
||||||
|
request.text_body.clone(),
|
||||||
|
request.html_body.clone(),
|
||||||
|
))
|
||||||
|
} else if !request.html_body.is_empty() {
|
||||||
|
BodyKind::Html(SinglePart::html(request.html_body.clone()))
|
||||||
|
} else {
|
||||||
|
BodyKind::Plain(SinglePart::plain(request.text_body.clone()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_single_attachment(
|
||||||
|
index: usize,
|
||||||
|
att: &Attachment,
|
||||||
|
) -> Result<SinglePart, EmailError> {
|
||||||
|
let filename = if att.filename.trim().is_empty() {
|
||||||
|
format!("attachment-{index}")
|
||||||
|
} else {
|
||||||
|
att.filename.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
if att.data.is_empty() && !att.url.trim().is_empty() {
|
||||||
|
return Err(EmailError::UnsupportedAttachmentUrl {
|
||||||
|
filename,
|
||||||
|
url: att.url.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let ct: ContentType = if att.content_type.trim().is_empty() {
|
||||||
|
"application/octet-stream"
|
||||||
|
.parse()
|
||||||
|
.expect("valid static mime")
|
||||||
|
} else {
|
||||||
|
att.content_type
|
||||||
|
.parse()
|
||||||
|
.map_err(|e: lettre::message::header::ContentTypeErr| {
|
||||||
|
EmailError::InvalidContentType {
|
||||||
|
filename: filename.clone(),
|
||||||
|
value: att.content_type.clone(),
|
||||||
|
reason: e.to_string(),
|
||||||
|
}
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
Ok(LettreAttachment::new(filename).body(Body::new(att.data.clone()), ct))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn mailbox_from_email_address(
|
||||||
|
field: &'static str,
|
||||||
|
value: &EmailAddress,
|
||||||
|
) -> Result<Mailbox, EmailError> {
|
||||||
|
mailbox_from_parts(field, &value.email, non_empty(&value.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn mailbox_from_parts(
|
||||||
|
field: &'static str,
|
||||||
|
email: &str,
|
||||||
|
name: Option<&str>,
|
||||||
|
) -> Result<Mailbox, EmailError> {
|
||||||
|
let address = email
|
||||||
|
.trim()
|
||||||
|
.parse::<Address>()
|
||||||
|
.map_err(|e| EmailError::InvalidAddress {
|
||||||
|
field,
|
||||||
|
value: email.to_owned(),
|
||||||
|
reason: e.to_string(),
|
||||||
|
})?;
|
||||||
|
Ok(Mailbox::new(name.map(ToOwned::to_owned), address))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn apply_custom_headers(
|
||||||
|
mut builder: lettre::message::MessageBuilder,
|
||||||
|
request: &SendEmailRequest,
|
||||||
|
) -> Result<lettre::message::MessageBuilder, EmailError> {
|
||||||
|
for (name, value) in &request.headers {
|
||||||
|
if non_empty(name).is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if is_managed_header(name) {
|
||||||
|
return Err(EmailError::ForbiddenHeader { name: name.clone() });
|
||||||
|
}
|
||||||
|
if value.contains(['\r', '\n']) {
|
||||||
|
return Err(EmailError::InvalidHeader {
|
||||||
|
name: name.clone(),
|
||||||
|
reason: "header value must not contain CR or LF".to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let hn =
|
||||||
|
HeaderName::new_from_ascii(name.clone()).map_err(|e| EmailError::InvalidHeader {
|
||||||
|
name: name.clone(),
|
||||||
|
reason: e.to_string(),
|
||||||
|
})?;
|
||||||
|
builder = builder.raw_header(HeaderValue::new(hn, value.clone()));
|
||||||
|
}
|
||||||
|
Ok(builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn apply_priority_headers(
|
||||||
|
builder: lettre::message::MessageBuilder,
|
||||||
|
priority: i32,
|
||||||
|
) -> lettre::message::MessageBuilder {
|
||||||
|
match EmailPriority::try_from(priority).unwrap_or(EmailPriority::Unspecified) {
|
||||||
|
EmailPriority::High => builder
|
||||||
|
.raw_header(hv("X-Priority", "1"))
|
||||||
|
.raw_header(hv("Importance", "high")),
|
||||||
|
EmailPriority::Low => builder
|
||||||
|
.raw_header(hv("X-Priority", "5"))
|
||||||
|
.raw_header(hv("Importance", "low")),
|
||||||
|
EmailPriority::Normal | EmailPriority::Unspecified => builder,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hv(name: &'static str, value: &str) -> HeaderValue {
|
||||||
|
HeaderValue::new(HeaderName::new_from_ascii_str(name), value.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_managed_header(name: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
name.trim().to_ascii_lowercase().as_str(),
|
||||||
|
"from"
|
||||||
|
| "to"
|
||||||
|
| "cc"
|
||||||
|
| "bcc"
|
||||||
|
| "subject"
|
||||||
|
| "reply-to"
|
||||||
|
| "date"
|
||||||
|
| "mime-version"
|
||||||
|
| "content-type"
|
||||||
|
| "content-transfer-encoding"
|
||||||
|
| "content-disposition"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn non_empty(value: &str) -> Option<&str> {
|
||||||
|
let v = value.trim();
|
||||||
|
(!v.is_empty()).then_some(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_message_from_parts(
|
||||||
|
config: &SmtpConfig,
|
||||||
|
request: &SendEmailRequest,
|
||||||
|
) -> Result<Message, EmailError> {
|
||||||
|
if request.to.is_empty() {
|
||||||
|
return Err(EmailError::MissingRecipients);
|
||||||
|
}
|
||||||
|
if request.text_body.is_empty() && request.html_body.is_empty() {
|
||||||
|
return Err(EmailError::BuildMessage(
|
||||||
|
"email must have at least a text or HTML body".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let from = resolve_sender(config, request)?;
|
||||||
|
let mut builder = Message::builder()
|
||||||
|
.from(from)
|
||||||
|
.subject(request.subject.clone());
|
||||||
|
|
||||||
|
for r in &request.to {
|
||||||
|
builder = builder.to(mailbox_from_email_address("to", r)?);
|
||||||
|
}
|
||||||
|
for r in &request.cc {
|
||||||
|
builder = builder.cc(mailbox_from_email_address("cc", r)?);
|
||||||
|
}
|
||||||
|
for r in &request.bcc {
|
||||||
|
builder = builder.bcc(mailbox_from_email_address("bcc", r)?);
|
||||||
|
}
|
||||||
|
if let Some(reply_to) = resolve_reply_to(config, request)? {
|
||||||
|
builder = builder.reply_to(reply_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
builder = apply_custom_headers(builder, request)?;
|
||||||
|
builder = apply_priority_headers(builder, request.priority);
|
||||||
|
|
||||||
|
if request.attachments.is_empty() {
|
||||||
|
return build_body(builder, request);
|
||||||
|
}
|
||||||
|
let mut mixed = build_mixed_body(request);
|
||||||
|
for (i, att) in request.attachments.iter().enumerate() {
|
||||||
|
mixed = mixed.singlepart(build_single_attachment(i, att)?);
|
||||||
|
}
|
||||||
|
builder
|
||||||
|
.multipart(mixed)
|
||||||
|
.map_err(|e| EmailError::BuildMessage(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_sender(config: &SmtpConfig, request: &SendEmailRequest) -> Result<Mailbox, EmailError> {
|
||||||
|
if let Some(from) = &request.from
|
||||||
|
&& !from.email.trim().is_empty()
|
||||||
|
{
|
||||||
|
if !config.allow_request_from {
|
||||||
|
return Err(EmailError::RequestSenderDisabled);
|
||||||
|
}
|
||||||
|
return mailbox_from_email_address("from", from);
|
||||||
|
}
|
||||||
|
let email = config
|
||||||
|
.from_email
|
||||||
|
.as_deref()
|
||||||
|
.filter(|v| !v.trim().is_empty())
|
||||||
|
.ok_or(EmailError::MissingSender)?;
|
||||||
|
mailbox_from_parts("from", email, config.from_name.as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_reply_to(
|
||||||
|
config: &SmtpConfig,
|
||||||
|
request: &SendEmailRequest,
|
||||||
|
) -> Result<Option<Mailbox>, EmailError> {
|
||||||
|
let email = non_empty(&request.reply_to).or(config.reply_to.as_deref());
|
||||||
|
email
|
||||||
|
.map(|v| mailbox_from_parts("reply_to", v, None))
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
const ENV_PREFIX: &str = "APP_SMTP_";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ConfigError {
|
||||||
|
MissingEnv { name: &'static str },
|
||||||
|
InvalidEnv { name: &'static str, reason: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum QueueError {
|
||||||
|
Closed,
|
||||||
|
Full,
|
||||||
|
IdExhausted,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum EmailError {
|
||||||
|
MissingSender,
|
||||||
|
MissingRecipients,
|
||||||
|
RequestSenderDisabled,
|
||||||
|
IncompleteCredentials,
|
||||||
|
InvalidAddress {
|
||||||
|
field: &'static str,
|
||||||
|
value: String,
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
InvalidContentType {
|
||||||
|
filename: String,
|
||||||
|
value: String,
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
InvalidHeader {
|
||||||
|
name: String,
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
ForbiddenHeader {
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
UnsupportedAttachmentUrl {
|
||||||
|
filename: String,
|
||||||
|
url: String,
|
||||||
|
},
|
||||||
|
BuildTransport(String),
|
||||||
|
BuildMessage(String),
|
||||||
|
Send(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ConfigError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::MissingEnv { name } => {
|
||||||
|
write!(f, "missing environment variable {ENV_PREFIX}{name}")
|
||||||
|
}
|
||||||
|
Self::InvalidEnv { name, reason } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"invalid environment variable {ENV_PREFIX}{name}: {reason}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for QueueError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Closed => f.write_str("email queue is closed"),
|
||||||
|
Self::Full => f.write_str("email queue is full"),
|
||||||
|
Self::IdExhausted => f.write_str("email queue id space is exhausted"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for EmailError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::MissingSender => {
|
||||||
|
f.write_str("missing sender: set request.from or APP_SMTP_FROM_EMAIL")
|
||||||
|
}
|
||||||
|
Self::MissingRecipients => {
|
||||||
|
f.write_str("missing recipients: request.to must not be empty")
|
||||||
|
}
|
||||||
|
Self::RequestSenderDisabled => {
|
||||||
|
f.write_str("request.from is disabled: use APP_SMTP_FROM_EMAIL or enable APP_SMTP_ALLOW_REQUEST_FROM")
|
||||||
|
}
|
||||||
|
Self::IncompleteCredentials => {
|
||||||
|
f.write_str("APP_SMTP_USERNAME and APP_SMTP_PASSWORD must be set together")
|
||||||
|
}
|
||||||
|
Self::InvalidAddress {
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
reason,
|
||||||
|
} => write!(f, "invalid email address in {field} ({value}): {reason}"),
|
||||||
|
Self::InvalidContentType {
|
||||||
|
filename,
|
||||||
|
value,
|
||||||
|
reason,
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"invalid content type for attachment {filename} ({value}): {reason}"
|
||||||
|
),
|
||||||
|
Self::InvalidHeader { name, reason } => {
|
||||||
|
write!(f, "invalid custom header {name}: {reason}")
|
||||||
|
}
|
||||||
|
Self::ForbiddenHeader { name } => {
|
||||||
|
write!(f, "custom header {name} is managed by the mail builder")
|
||||||
|
}
|
||||||
|
Self::UnsupportedAttachmentUrl { filename, url } => write!(
|
||||||
|
f,
|
||||||
|
"attachment {filename} uses url {url}, but URL attachment fetching is not supported"
|
||||||
|
),
|
||||||
|
Self::BuildTransport(reason) => write!(f, "failed to build SMTP transport: {reason}"),
|
||||||
|
Self::BuildMessage(reason) => write!(f, "failed to build email message: {reason}"),
|
||||||
|
Self::Send(reason) => write!(f, "failed to send email: {reason}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ConfigError {}
|
||||||
|
impl std::error::Error for QueueError {}
|
||||||
|
impl std::error::Error for EmailError {}
|
||||||
|
|
||||||
|
impl EmailError {
|
||||||
|
/// 返回 true 表示该错误不可重试,应直接销毁任务
|
||||||
|
pub fn is_terminal(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Self::MissingSender
|
||||||
|
| Self::MissingRecipients
|
||||||
|
| Self::RequestSenderDisabled
|
||||||
|
| Self::IncompleteCredentials
|
||||||
|
| Self::InvalidAddress { .. }
|
||||||
|
| Self::InvalidContentType { .. }
|
||||||
|
| Self::InvalidHeader { .. }
|
||||||
|
| Self::ForbiddenHeader { .. }
|
||||||
|
| Self::UnsupportedAttachmentUrl { .. }
|
||||||
|
| Self::BuildTransport(_)
|
||||||
|
| Self::BuildMessage(_)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_retryable(&self) -> bool {
|
||||||
|
!self.is_terminal()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
pub mod config;
|
||||||
|
pub mod email;
|
||||||
|
pub mod email_build;
|
||||||
|
pub mod error;
|
||||||
|
pub mod queue;
|
||||||
|
pub mod server;
|
||||||
|
pub mod status;
|
||||||
|
|
||||||
|
pub mod pb {
|
||||||
|
pub mod email {
|
||||||
|
pub mod v1 {
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/pb/email.v1.rs"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
use emailks::{
|
||||||
|
config::AppConfig, email::EmailSender, pb::email::v1::email_service_server::EmailServiceServer,
|
||||||
|
queue::EmailQueue, server::EmailServiceImpl,
|
||||||
|
};
|
||||||
|
use tonic::transport::Server;
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
const DEFAULT_QUEUE_CAPACITY: usize = 1_000;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let config = AppConfig::from_env()?;
|
||||||
|
info!(?config.smtp.host, port = config.smtp.port, "smtp config loaded");
|
||||||
|
|
||||||
|
let sender = EmailSender::new(config.smtp)?;
|
||||||
|
let (queue, worker) = match config.queue_capacity {
|
||||||
|
Some(0) => {
|
||||||
|
info!("creating unbounded queue by explicit configuration");
|
||||||
|
EmailQueue::unbounded()
|
||||||
|
}
|
||||||
|
Some(cap) => {
|
||||||
|
info!(capacity = cap, "creating bounded queue");
|
||||||
|
EmailQueue::bounded(cap)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
info!(
|
||||||
|
capacity = DEFAULT_QUEUE_CAPACITY,
|
||||||
|
"creating bounded queue with default capacity"
|
||||||
|
);
|
||||||
|
EmailQueue::bounded(DEFAULT_QUEUE_CAPACITY)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let store = queue.status_store().clone();
|
||||||
|
let worker_handle = worker.spawn(move |job| {
|
||||||
|
let s = sender.clone();
|
||||||
|
async move { s.send_job(&job).await }
|
||||||
|
});
|
||||||
|
|
||||||
|
let addr = config.listen_addr;
|
||||||
|
let svc = EmailServiceImpl::new(queue, store);
|
||||||
|
|
||||||
|
let (health, health_svc) = tonic_health::server::health_reporter();
|
||||||
|
health
|
||||||
|
.set_serving::<EmailServiceServer<EmailServiceImpl>>()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
info!(%addr, "gRPC server starting");
|
||||||
|
Server::builder()
|
||||||
|
.add_service(health_svc)
|
||||||
|
.add_service(EmailServiceServer::new(svc))
|
||||||
|
.serve_with_shutdown(addr, shutdown_signal())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
info!("server stopped");
|
||||||
|
|
||||||
|
if let Err(e) = worker_handle.await {
|
||||||
|
tracing::error!(error = %e, "worker task panicked");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn shutdown_signal() {
|
||||||
|
match tokio::signal::ctrl_c().await {
|
||||||
|
Ok(()) => info!("shutdown signal received, draining..."),
|
||||||
|
Err(err) => error!(%err, "failed to install CTRL+C handler, shutting down"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
package email.v1;
|
||||||
|
|
||||||
|
import "google/protobuf/timestamp.proto";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
enum EmailPriority {
|
||||||
|
EMAIL_PRIORITY_UNSPECIFIED = 0;
|
||||||
|
EMAIL_PRIORITY_LOW = 1;
|
||||||
|
EMAIL_PRIORITY_NORMAL = 2;
|
||||||
|
EMAIL_PRIORITY_HIGH = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SendStatus {
|
||||||
|
SEND_STATUS_UNSPECIFIED = 0;
|
||||||
|
SEND_STATUS_QUEUED = 1;
|
||||||
|
SEND_STATUS_SENT = 2;
|
||||||
|
SEND_STATUS_FAILED = 3;
|
||||||
|
SEND_STATUS_SENDING = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
message EmailAddress {
|
||||||
|
string email = 1;
|
||||||
|
string name = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Attachment {
|
||||||
|
string filename = 1;
|
||||||
|
string content_type = 2;
|
||||||
|
bytes data = 3;
|
||||||
|
string url = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message SendEmailRequest {
|
||||||
|
EmailAddress from = 1;
|
||||||
|
repeated EmailAddress to = 2;
|
||||||
|
repeated EmailAddress cc = 3;
|
||||||
|
repeated EmailAddress bcc = 4;
|
||||||
|
string subject = 5;
|
||||||
|
string text_body = 6;
|
||||||
|
string html_body = 7;
|
||||||
|
repeated Attachment attachments = 8;
|
||||||
|
EmailPriority priority = 9;
|
||||||
|
map<string, string> headers = 10;
|
||||||
|
string reply_to = 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SendEmailResponse {
|
||||||
|
string message_id = 1;
|
||||||
|
SendStatus status = 2;
|
||||||
|
string provider = 3;
|
||||||
|
google.protobuf.Timestamp sent_at = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message BatchSendEmailRequest {
|
||||||
|
repeated SendEmailRequest emails = 1;
|
||||||
|
bool fail_fast = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message BatchSendEmailResponse {
|
||||||
|
repeated SendEmailResponse results = 1;
|
||||||
|
int32 success_count = 2;
|
||||||
|
int32 failure_count = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetEmailStatusRequest {
|
||||||
|
string message_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetEmailStatusResponse {
|
||||||
|
string message_id = 1;
|
||||||
|
SendStatus status = 2;
|
||||||
|
string error_detail = 3;
|
||||||
|
google.protobuf.Timestamp updated_at = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
service EmailService {
|
||||||
|
rpc SendEmail(SendEmailRequest) returns (SendEmailResponse);
|
||||||
|
rpc BatchSendEmail(BatchSendEmailRequest) returns (BatchSendEmailResponse);
|
||||||
|
rpc GetEmailStatus(GetEmailStatusRequest) returns (GetEmailStatusResponse);
|
||||||
|
rpc StreamBatchStatus(BatchSendEmailRequest)
|
||||||
|
returns (stream SendEmailResponse);
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
use std::{
|
||||||
|
fmt,
|
||||||
|
future::Future,
|
||||||
|
sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicU64, Ordering},
|
||||||
|
},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use tokio::sync::{mpsc, watch};
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
pub use crate::error::QueueError;
|
||||||
|
use crate::{error::EmailError, pb::email::v1::SendEmailRequest, status::JobStatusStore};
|
||||||
|
|
||||||
|
pub const MAX_FAILURES: u8 = 3;
|
||||||
|
const RETRY_BASE_DELAY: Duration = Duration::from_millis(100);
|
||||||
|
const RETRY_MAX_DELAY: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct EmailQueue {
|
||||||
|
sender: QueueSender,
|
||||||
|
next_id: Arc<AtomicU64>,
|
||||||
|
status_store: JobStatusStore,
|
||||||
|
shutdown: Arc<QueueShutdown>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct EmailQueueWorker {
|
||||||
|
receiver: QueueReceiver,
|
||||||
|
requeue_sender: QueueSender,
|
||||||
|
status_store: JobStatusStore,
|
||||||
|
shutdown_rx: watch::Receiver<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct EmailJob {
|
||||||
|
pub id: u64,
|
||||||
|
pub request: Arc<SendEmailRequest>,
|
||||||
|
pub failed_attempts: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum QueueSender {
|
||||||
|
Unbounded(mpsc::UnboundedSender<EmailJob>),
|
||||||
|
Bounded(mpsc::Sender<EmailJob>),
|
||||||
|
}
|
||||||
|
|
||||||
|
enum QueueReceiver {
|
||||||
|
Unbounded(mpsc::UnboundedReceiver<EmailJob>),
|
||||||
|
Bounded(mpsc::Receiver<EmailJob>),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct QueueShutdown {
|
||||||
|
tx: watch::Sender<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for QueueShutdown {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = self.tx.send(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_shutdown_pair() -> (Arc<QueueShutdown>, watch::Receiver<bool>) {
|
||||||
|
let (tx, rx) = watch::channel(false);
|
||||||
|
(Arc::new(QueueShutdown { tx }), rx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retry_delay(attempt: u8, job_id: u64) -> Duration {
|
||||||
|
let multiplier = 1u32 << u32::from(attempt.saturating_sub(1)).min(8);
|
||||||
|
let base = RETRY_BASE_DELAY.saturating_mul(multiplier);
|
||||||
|
let jitter = Duration::from_millis(job_id % 100);
|
||||||
|
base.saturating_add(jitter).min(RETRY_MAX_DELAY)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueueSender {
|
||||||
|
fn send(&self, job: EmailJob) -> Result<(), QueueError> {
|
||||||
|
match self {
|
||||||
|
Self::Unbounded(tx) => tx.send(job).map_err(|_| QueueError::Closed),
|
||||||
|
Self::Bounded(tx) => tx.try_send(job).map_err(|e| match e {
|
||||||
|
mpsc::error::TrySendError::Full(_) => QueueError::Full,
|
||||||
|
mpsc::error::TrySendError::Closed(_) => QueueError::Closed,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueueReceiver {
|
||||||
|
async fn recv(&mut self) -> Option<EmailJob> {
|
||||||
|
match self {
|
||||||
|
Self::Unbounded(rx) => rx.recv().await,
|
||||||
|
Self::Bounded(rx) => rx.recv().await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EmailQueue {
|
||||||
|
pub fn unbounded() -> (Self, EmailQueueWorker) {
|
||||||
|
let store = JobStatusStore::new();
|
||||||
|
Self::build(mpsc::unbounded_channel(), store)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bounded(capacity: usize) -> (Self, EmailQueueWorker) {
|
||||||
|
let store = JobStatusStore::new();
|
||||||
|
Self::build_bounded(mpsc::channel(capacity), store)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(
|
||||||
|
(tx, rx): (
|
||||||
|
mpsc::UnboundedSender<EmailJob>,
|
||||||
|
mpsc::UnboundedReceiver<EmailJob>,
|
||||||
|
),
|
||||||
|
store: JobStatusStore,
|
||||||
|
) -> (Self, EmailQueueWorker) {
|
||||||
|
let (shutdown, shutdown_rx) = new_shutdown_pair();
|
||||||
|
let sender = QueueSender::Unbounded(tx);
|
||||||
|
let queue = Self {
|
||||||
|
sender: sender.clone(),
|
||||||
|
next_id: Arc::new(AtomicU64::new(1)),
|
||||||
|
status_store: store.clone(),
|
||||||
|
shutdown,
|
||||||
|
};
|
||||||
|
let worker = EmailQueueWorker {
|
||||||
|
receiver: QueueReceiver::Unbounded(rx),
|
||||||
|
requeue_sender: sender,
|
||||||
|
status_store: store,
|
||||||
|
shutdown_rx,
|
||||||
|
};
|
||||||
|
(queue, worker)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_bounded(
|
||||||
|
(tx, rx): (mpsc::Sender<EmailJob>, mpsc::Receiver<EmailJob>),
|
||||||
|
store: JobStatusStore,
|
||||||
|
) -> (Self, EmailQueueWorker) {
|
||||||
|
let (shutdown, shutdown_rx) = new_shutdown_pair();
|
||||||
|
let sender = QueueSender::Bounded(tx.clone());
|
||||||
|
let queue = Self {
|
||||||
|
sender: sender.clone(),
|
||||||
|
next_id: Arc::new(AtomicU64::new(1)),
|
||||||
|
status_store: store.clone(),
|
||||||
|
shutdown,
|
||||||
|
};
|
||||||
|
let worker = EmailQueueWorker {
|
||||||
|
receiver: QueueReceiver::Bounded(rx),
|
||||||
|
requeue_sender: sender,
|
||||||
|
status_store: store,
|
||||||
|
shutdown_rx,
|
||||||
|
};
|
||||||
|
(queue, worker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EmailQueue {
|
||||||
|
pub fn enqueue(&self, request: SendEmailRequest) -> Result<u64, QueueError> {
|
||||||
|
if *self.shutdown.tx.borrow() {
|
||||||
|
return Err(QueueError::Closed);
|
||||||
|
}
|
||||||
|
let id = self.next_job_id()?;
|
||||||
|
self.status_store.set_queued(id);
|
||||||
|
let job = EmailJob {
|
||||||
|
id,
|
||||||
|
request: Arc::new(request),
|
||||||
|
failed_attempts: 0,
|
||||||
|
};
|
||||||
|
if let Err(err) = self.sender.send(job) {
|
||||||
|
self.status_store.remove(id);
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
info!(id, "email job enqueued");
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enqueues multiple email requests.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(ids)` on full success. On partial failure, returns
|
||||||
|
/// `Err(QueueError)` — note that some emails may have already been
|
||||||
|
/// enqueued before the failure. Callers should handle duplicates
|
||||||
|
/// if retrying the full batch.
|
||||||
|
pub fn enqueue_batch<I>(&self, requests: I) -> Result<Vec<u64>, QueueError>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = SendEmailRequest>,
|
||||||
|
{
|
||||||
|
requests.into_iter().map(|r| self.enqueue(r)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status_store(&self) -> &JobStatusStore {
|
||||||
|
&self.status_store
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_job_id(&self) -> Result<u64, QueueError> {
|
||||||
|
self.next_id
|
||||||
|
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |id| id.checked_add(1))
|
||||||
|
.map_err(|_| QueueError::IdExhausted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EmailQueueWorker {
|
||||||
|
pub async fn run<F, Fut>(mut self, mut consume: F)
|
||||||
|
where
|
||||||
|
F: FnMut(EmailJob) -> Fut,
|
||||||
|
Fut: Future<Output = Result<(), EmailError>>,
|
||||||
|
{
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
changed = self.shutdown_rx.changed() => {
|
||||||
|
if changed.is_err() || *self.shutdown_rx.borrow() {
|
||||||
|
info!("queue worker stopped: shutdown requested");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
job = self.receiver.recv() => {
|
||||||
|
let Some(job) = job else {
|
||||||
|
info!("queue worker stopped: channel closed");
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
self.consume_job(job, &mut consume).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn<F, Fut>(self, consume: F) -> tokio::task::JoinHandle<()>
|
||||||
|
where
|
||||||
|
F: FnMut(EmailJob) -> Fut + Send + 'static,
|
||||||
|
Fut: Future<Output = Result<(), EmailError>> + Send + 'static,
|
||||||
|
{
|
||||||
|
tokio::spawn(async move { self.run(consume).await })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn consume_job<F, Fut>(&self, mut job: EmailJob, consume: &mut F)
|
||||||
|
where
|
||||||
|
F: FnMut(EmailJob) -> Fut,
|
||||||
|
Fut: Future<Output = Result<(), EmailError>>,
|
||||||
|
{
|
||||||
|
self.status_store.set_sending(job.id);
|
||||||
|
|
||||||
|
match consume(job.clone()).await {
|
||||||
|
Ok(()) => {
|
||||||
|
info!(id = job.id, "email sent");
|
||||||
|
self.status_store.set_sent(job.id);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
if err.is_terminal() {
|
||||||
|
warn!(id = job.id, %err, "terminal error, destroying job");
|
||||||
|
self.status_store.set_failed(job.id, err.to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
job.failed_attempts = job.failed_attempts.saturating_add(1);
|
||||||
|
if job.failed_attempts >= MAX_FAILURES {
|
||||||
|
error!(
|
||||||
|
id = job.id,
|
||||||
|
%err,
|
||||||
|
attempts = job.failed_attempts,
|
||||||
|
"max failures reached, destroying job"
|
||||||
|
);
|
||||||
|
self.status_store.set_failed(job.id, err.to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let delay = retry_delay(job.failed_attempts, job.id);
|
||||||
|
warn!(
|
||||||
|
id = job.id,
|
||||||
|
%err,
|
||||||
|
attempt = job.failed_attempts,
|
||||||
|
max = MAX_FAILURES,
|
||||||
|
delay_ms = delay.as_millis(),
|
||||||
|
"retryable failure, requeuing after backoff"
|
||||||
|
);
|
||||||
|
|
||||||
|
if *self.shutdown_rx.borrow() {
|
||||||
|
self.status_store
|
||||||
|
.set_failed(job.id, "shutdown before retry".to_owned());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut shutdown_rx = self.shutdown_rx.clone();
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(delay) => {}
|
||||||
|
changed = shutdown_rx.changed() => {
|
||||||
|
if changed.is_err() || *shutdown_rx.borrow() {
|
||||||
|
self.status_store.set_failed(job.id, "shutdown before retry".to_owned());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let requeue_id = job.id;
|
||||||
|
if let Err(e) = self.requeue_sender.send(job) {
|
||||||
|
error!(id = requeue_id, %e, "failed to requeue");
|
||||||
|
self.status_store
|
||||||
|
.set_failed(requeue_id, format!("requeue failed: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status_store(&self) -> &JobStatusStore {
|
||||||
|
&self.status_store
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for EmailJob {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("EmailJob")
|
||||||
|
.field("id", &self.id)
|
||||||
|
.field("failed_attempts", &self.failed_attempts)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
use std::pin::Pin;
|
||||||
|
|
||||||
|
use tokio::time::{self, Duration, Instant};
|
||||||
|
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio_stream::{Stream, wrappers::ReceiverStream};
|
||||||
|
use tonic::{Request, Response, Status};
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
const STREAM_STATUS_POLL_INTERVAL: Duration = Duration::from_millis(300);
|
||||||
|
const STREAM_STATUS_TIMEOUT: Duration = Duration::from_secs(10 * 60);
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
error::QueueError,
|
||||||
|
pb::email::v1::{
|
||||||
|
BatchSendEmailRequest, BatchSendEmailResponse, GetEmailStatusRequest,
|
||||||
|
GetEmailStatusResponse, SendEmailRequest, SendEmailResponse, SendStatus,
|
||||||
|
email_service_server::EmailService,
|
||||||
|
},
|
||||||
|
queue::EmailQueue,
|
||||||
|
status::JobStatusStore,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct EmailServiceImpl {
|
||||||
|
queue: EmailQueue,
|
||||||
|
store: JobStatusStore,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EmailServiceImpl {
|
||||||
|
pub fn new(queue: EmailQueue, store: JobStatusStore) -> Self {
|
||||||
|
Self { queue, store }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_queue_err(err: QueueError) -> Status {
|
||||||
|
match err {
|
||||||
|
QueueError::Closed => Status::unavailable("queue closed"),
|
||||||
|
QueueError::Full => Status::resource_exhausted("queue full, try later"),
|
||||||
|
QueueError::IdExhausted => Status::resource_exhausted("queue id space exhausted"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_response(id: u64, status: SendStatus) -> SendEmailResponse {
|
||||||
|
SendEmailResponse {
|
||||||
|
message_id: id.to_string(),
|
||||||
|
status: status.into(),
|
||||||
|
provider: String::new(),
|
||||||
|
sent_at: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_failed_response(id: Option<u64>, detail: String) -> SendEmailResponse {
|
||||||
|
SendEmailResponse {
|
||||||
|
message_id: id.map(|v| v.to_string()).unwrap_or_default(),
|
||||||
|
status: SendStatus::Failed.into(),
|
||||||
|
provider: detail,
|
||||||
|
sent_at: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tonic::async_trait]
|
||||||
|
impl EmailService for EmailServiceImpl {
|
||||||
|
async fn send_email(
|
||||||
|
&self,
|
||||||
|
request: Request<SendEmailRequest>,
|
||||||
|
) -> Result<Response<SendEmailResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let id = self.queue.enqueue(req).map_err(map_queue_err)?;
|
||||||
|
Ok(Response::new(build_response(id, SendStatus::Queued)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn batch_send_email(
|
||||||
|
&self,
|
||||||
|
request: Request<BatchSendEmailRequest>,
|
||||||
|
) -> Result<Response<BatchSendEmailResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let total = req.emails.len();
|
||||||
|
let mut success = 0i32;
|
||||||
|
let mut failures = 0i32;
|
||||||
|
let mut results = Vec::with_capacity(total);
|
||||||
|
|
||||||
|
for email in req.emails {
|
||||||
|
match self.queue.enqueue(email) {
|
||||||
|
Ok(id) => {
|
||||||
|
success += 1;
|
||||||
|
results.push(build_response(id, SendStatus::Queued));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
failures += 1;
|
||||||
|
warn!(%e, "batch enqueue failed for one email");
|
||||||
|
if req.fail_fast {
|
||||||
|
warn!(
|
||||||
|
successful = success,
|
||||||
|
failed = failures,
|
||||||
|
"fail_fast triggered, returning partial results"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Response::new(BatchSendEmailResponse {
|
||||||
|
results,
|
||||||
|
success_count: success,
|
||||||
|
failure_count: failures,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_email_status(
|
||||||
|
&self,
|
||||||
|
request: Request<GetEmailStatusRequest>,
|
||||||
|
) -> Result<Response<GetEmailStatusResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let id: u64 = req
|
||||||
|
.message_id
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| Status::invalid_argument("message_id must be a valid u64"))?;
|
||||||
|
|
||||||
|
let entry = self
|
||||||
|
.store
|
||||||
|
.get(id)
|
||||||
|
.ok_or_else(|| Status::not_found(format!("message_id {id} not found")))?;
|
||||||
|
|
||||||
|
Ok(Response::new(GetEmailStatusResponse {
|
||||||
|
message_id: id.to_string(),
|
||||||
|
status: entry.status.into(),
|
||||||
|
error_detail: entry.error.unwrap_or_default(),
|
||||||
|
updated_at: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
type StreamBatchStatusStream =
|
||||||
|
Pin<Box<dyn Stream<Item = Result<SendEmailResponse, Status>> + Send>>;
|
||||||
|
|
||||||
|
async fn stream_batch_status(
|
||||||
|
&self,
|
||||||
|
request: Request<BatchSendEmailRequest>,
|
||||||
|
) -> Result<Response<Self::StreamBatchStatusStream>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let mut ids = Vec::with_capacity(req.emails.len());
|
||||||
|
let mut immediate_results = Vec::new();
|
||||||
|
|
||||||
|
for email in req.emails {
|
||||||
|
match self.queue.enqueue(email) {
|
||||||
|
Ok(id) => ids.push(id),
|
||||||
|
Err(err) => {
|
||||||
|
immediate_results.push(Ok(build_failed_response(None, err.to_string())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let id_set: std::collections::HashSet<u64> = ids.iter().copied().collect();
|
||||||
|
let store = self.store.clone();
|
||||||
|
let (tx, rx) = mpsc::channel(ids.len().saturating_add(immediate_results.len()).max(1));
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
for result in immediate_results {
|
||||||
|
if tx.send(result).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut interval = time::interval(STREAM_STATUS_POLL_INTERVAL);
|
||||||
|
let deadline = Instant::now() + STREAM_STATUS_TIMEOUT;
|
||||||
|
let mut reported = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = tx.closed() => return,
|
||||||
|
_ = time::sleep_until(deadline) => {
|
||||||
|
for id in id_set.difference(&reported) {
|
||||||
|
let response = build_failed_response(
|
||||||
|
Some(*id),
|
||||||
|
"status stream timed out before terminal state".to_owned(),
|
||||||
|
);
|
||||||
|
if tx.send(Ok(response)).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ = interval.tick() => {
|
||||||
|
for id in &id_set {
|
||||||
|
if reported.contains(id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(entry) = store.get(*id) {
|
||||||
|
match entry.status {
|
||||||
|
SendStatus::Sent => {
|
||||||
|
if tx
|
||||||
|
.send(Ok(build_response(*id, SendStatus::Sent)))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reported.insert(*id);
|
||||||
|
}
|
||||||
|
SendStatus::Failed => {
|
||||||
|
let response = build_failed_response(
|
||||||
|
Some(*id),
|
||||||
|
entry.error.unwrap_or_else(|| "unknown".into()),
|
||||||
|
);
|
||||||
|
if tx.send(Ok(response)).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reported.insert(*id);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if reported.len() == id_set.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let stream: Self::StreamBatchStatusStream = Box::pin(ReceiverStream::new(rx));
|
||||||
|
Ok(Response::new(stream))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
sync::{Arc, RwLock},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use tracing;
|
||||||
|
|
||||||
|
use crate::pb::email::v1::SendStatus;
|
||||||
|
|
||||||
|
const STATUS_TTL: Duration = Duration::from_secs(24 * 60 * 60);
|
||||||
|
const MAX_STATUS_ENTRIES: usize = 10_000;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct JobStatusStore {
|
||||||
|
inner: Arc<RwLock<HashMap<u64, JobStatusEntry>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct JobStatusEntry {
|
||||||
|
pub status: SendStatus,
|
||||||
|
pub error: Option<String>,
|
||||||
|
updated_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JobStatusStore {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_queued(&self, id: u64) {
|
||||||
|
self.write(id, SendStatus::Queued, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_sending(&self, id: u64) {
|
||||||
|
self.write(id, SendStatus::Sending, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_sent(&self, id: u64) {
|
||||||
|
self.write(id, SendStatus::Sent, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_failed(&self, id: u64, error: String) {
|
||||||
|
self.write(id, SendStatus::Failed, Some(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, id: u64) -> Option<JobStatusEntry> {
|
||||||
|
let guard = match self.inner.read() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(poisoned) => {
|
||||||
|
tracing::error!("JobStatusStore read lock poisoned, recovering");
|
||||||
|
poisoned.into_inner()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
guard.get(&id).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&self, id: u64) {
|
||||||
|
let mut guard = match self.inner.write() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(poisoned) => {
|
||||||
|
tracing::error!("JobStatusStore write lock poisoned, recovering");
|
||||||
|
poisoned.into_inner()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
guard.remove(&id);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn all_done(&self, ids: &[u64]) -> bool {
|
||||||
|
let guard = match self.inner.read() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
ids.iter().all(|id| {
|
||||||
|
matches!(
|
||||||
|
guard.get(id).map(|e| e.status),
|
||||||
|
Some(SendStatus::Sent | SendStatus::Failed)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(&self, id: u64, status: SendStatus, error: Option<String>) {
|
||||||
|
let mut guard = match self.inner.write() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(poisoned) => {
|
||||||
|
tracing::error!("JobStatusStore write lock poisoned, recovering");
|
||||||
|
poisoned.into_inner()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
prune_statuses(&mut guard);
|
||||||
|
guard.insert(
|
||||||
|
id,
|
||||||
|
JobStatusEntry {
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
updated_at: Instant::now(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prune_statuses(entries: &mut HashMap<u64, JobStatusEntry>) {
|
||||||
|
let now = Instant::now();
|
||||||
|
entries.retain(|_, entry| now.duration_since(entry.updated_at) <= STATUS_TTL);
|
||||||
|
|
||||||
|
while entries.len() >= MAX_STATUS_ENTRIES {
|
||||||
|
let Some(oldest_id) = entries
|
||||||
|
.iter()
|
||||||
|
.min_by_key(|(_, entry)| entry.updated_at)
|
||||||
|
.map(|(id, _)| *id)
|
||||||
|
else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
entries.remove(&oldest_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
use emailks::config::{AppConfig, SmtpTls};
|
||||||
|
use emailks::error::ConfigError;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Serialize tests that mutate process-wide environment variables.
|
||||||
|
static TEST_MUTEX: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
macro_rules! set_env {
|
||||||
|
($k:expr, $v:expr) => {
|
||||||
|
unsafe { std::env::set_var($k, $v) }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
macro_rules! rm_env {
|
||||||
|
($k:expr) => {
|
||||||
|
unsafe { std::env::remove_var($k) }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_smtp_env() {
|
||||||
|
for var in &[
|
||||||
|
"APP_SMTP_HOST",
|
||||||
|
"APP_SMTP_PORT",
|
||||||
|
"APP_SMTP_USERNAME",
|
||||||
|
"APP_SMTP_PASSWORD",
|
||||||
|
"APP_SMTP_FROM_EMAIL",
|
||||||
|
"APP_SMTP_FROM_NAME",
|
||||||
|
"APP_SMTP_REPLY_TO",
|
||||||
|
"APP_SMTP_TLS",
|
||||||
|
"APP_SMTP_TIMEOUT_SECS",
|
||||||
|
"APP_SMTP_HELO_NAME",
|
||||||
|
"APP_SMTP_ALLOW_REQUEST_FROM",
|
||||||
|
"APP_SMTP_LISTEN_ADDR",
|
||||||
|
"APP_SMTP_QUEUE_CAPACITY",
|
||||||
|
] {
|
||||||
|
rm_env!(var);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_smtp_config_full() {
|
||||||
|
let _guard = TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
clear_smtp_env();
|
||||||
|
set_env!("APP_SMTP_HOST", "smtp.test.com");
|
||||||
|
set_env!("APP_SMTP_PORT", "465");
|
||||||
|
set_env!("APP_SMTP_USERNAME", "user");
|
||||||
|
set_env!("APP_SMTP_PASSWORD", "pass");
|
||||||
|
set_env!("APP_SMTP_FROM_EMAIL", "from@test.com");
|
||||||
|
set_env!("APP_SMTP_FROM_NAME", "Test");
|
||||||
|
set_env!("APP_SMTP_REPLY_TO", "reply@test.com");
|
||||||
|
set_env!("APP_SMTP_TLS", "tls");
|
||||||
|
set_env!("APP_SMTP_TIMEOUT_SECS", "60");
|
||||||
|
set_env!("APP_SMTP_HELO_NAME", "helo.test.com");
|
||||||
|
|
||||||
|
let s = &AppConfig::from_env().unwrap().smtp;
|
||||||
|
assert_eq!(s.host, "smtp.test.com");
|
||||||
|
assert_eq!(s.port, 465);
|
||||||
|
assert_eq!(s.username.as_deref(), Some("user"));
|
||||||
|
assert_eq!(s.password.as_deref(), Some("pass"));
|
||||||
|
assert_eq!(s.from_email.as_deref(), Some("from@test.com"));
|
||||||
|
assert_eq!(s.from_name.as_deref(), Some("Test"));
|
||||||
|
assert_eq!(s.reply_to.as_deref(), Some("reply@test.com"));
|
||||||
|
assert_eq!(s.tls, SmtpTls::Tls);
|
||||||
|
assert_eq!(s.timeout, Duration::from_secs(60));
|
||||||
|
assert_eq!(s.helo_name.as_deref(), Some("helo.test.com"));
|
||||||
|
assert!(!s.allow_request_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_smtp_config_minimal() {
|
||||||
|
let _guard = TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
clear_smtp_env();
|
||||||
|
set_env!("APP_SMTP_HOST", "smtp.test.com");
|
||||||
|
|
||||||
|
let s = &AppConfig::from_env().unwrap().smtp;
|
||||||
|
assert_eq!(s.host, "smtp.test.com");
|
||||||
|
assert_eq!(s.port, 587);
|
||||||
|
assert_eq!(s.tls, SmtpTls::StartTls);
|
||||||
|
assert_eq!(s.timeout, Duration::from_secs(30));
|
||||||
|
assert!(s.username.is_none());
|
||||||
|
assert_eq!(
|
||||||
|
AppConfig::from_env().unwrap().listen_addr.to_string(),
|
||||||
|
"127.0.0.1:50051"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_host_is_error() {
|
||||||
|
let _guard = TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
clear_smtp_env();
|
||||||
|
let err = AppConfig::from_env().unwrap_err();
|
||||||
|
assert!(matches!(err, ConfigError::MissingEnv { name: "HOST" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tls_variants() {
|
||||||
|
let _guard = TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
clear_smtp_env();
|
||||||
|
set_env!("APP_SMTP_HOST", "h");
|
||||||
|
for (value, expected) in [
|
||||||
|
("none", SmtpTls::None),
|
||||||
|
("false", SmtpTls::None),
|
||||||
|
("starttls", SmtpTls::StartTls),
|
||||||
|
("start_tls", SmtpTls::StartTls),
|
||||||
|
("tls", SmtpTls::Tls),
|
||||||
|
("ssl", SmtpTls::Tls),
|
||||||
|
] {
|
||||||
|
set_env!("APP_SMTP_TLS", value);
|
||||||
|
assert_eq!(AppConfig::from_env().unwrap().smtp.tls, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_port_is_error() {
|
||||||
|
let _guard = TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
clear_smtp_env();
|
||||||
|
set_env!("APP_SMTP_HOST", "h");
|
||||||
|
set_env!("APP_SMTP_PORT", "0");
|
||||||
|
assert!(matches!(
|
||||||
|
AppConfig::from_env().unwrap_err(),
|
||||||
|
ConfigError::InvalidEnv { name: "PORT", .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn queue_capacity_parsing() {
|
||||||
|
let _guard = TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
clear_smtp_env();
|
||||||
|
set_env!("APP_SMTP_HOST", "h");
|
||||||
|
set_env!("APP_SMTP_QUEUE_CAPACITY", "100");
|
||||||
|
assert_eq!(AppConfig::from_env().unwrap().queue_capacity, Some(100));
|
||||||
|
|
||||||
|
rm_env!("APP_SMTP_QUEUE_CAPACITY");
|
||||||
|
assert_eq!(AppConfig::from_env().unwrap().queue_capacity, None);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
use emailks::{
|
||||||
|
email_build::build_message_from_parts,
|
||||||
|
error::EmailError,
|
||||||
|
pb::email::v1::{EmailAddress, SendEmailRequest},
|
||||||
|
};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
fn test_config() -> emailks::config::SmtpConfig {
|
||||||
|
emailks::config::SmtpConfig {
|
||||||
|
host: "localhost".into(),
|
||||||
|
port: 1025,
|
||||||
|
username: None,
|
||||||
|
password: None,
|
||||||
|
from_email: Some("sender@test.com".into()),
|
||||||
|
from_name: Some("Sender".into()),
|
||||||
|
reply_to: None,
|
||||||
|
tls: emailks::config::SmtpTls::None,
|
||||||
|
timeout: Duration::from_secs(5),
|
||||||
|
helo_name: None,
|
||||||
|
allow_request_from: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn req_with_to(to: &str) -> SendEmailRequest {
|
||||||
|
SendEmailRequest {
|
||||||
|
from: None,
|
||||||
|
to: vec![EmailAddress {
|
||||||
|
email: to.into(),
|
||||||
|
name: String::new(),
|
||||||
|
}],
|
||||||
|
cc: vec![],
|
||||||
|
bcc: vec![],
|
||||||
|
subject: "Hello".into(),
|
||||||
|
text_body: "World".into(),
|
||||||
|
html_body: String::new(),
|
||||||
|
attachments: vec![],
|
||||||
|
priority: 0,
|
||||||
|
headers: Default::default(),
|
||||||
|
reply_to: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sender_from_request() {
|
||||||
|
let mut cfg = test_config();
|
||||||
|
cfg.allow_request_from = true;
|
||||||
|
let mut req = req_with_to("to@test.com");
|
||||||
|
req.from = Some(EmailAddress {
|
||||||
|
email: "req@test.com".into(),
|
||||||
|
name: "Request".into(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let msg = build_message_from_parts(&cfg, &req).unwrap();
|
||||||
|
let body = String::from_utf8(msg.formatted()).unwrap();
|
||||||
|
assert!(body.contains("From: Request <req@test.com>"));
|
||||||
|
assert!(body.contains("To: to@test.com"));
|
||||||
|
assert!(body.contains("Subject: Hello"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sender_from_config() {
|
||||||
|
let mut req = req_with_to("to@test.com");
|
||||||
|
req.text_body = String::new();
|
||||||
|
req.html_body = "<p>Hi</p>".into();
|
||||||
|
|
||||||
|
let msg = build_message_from_parts(&test_config(), &req).unwrap();
|
||||||
|
let body = String::from_utf8(msg.formatted()).unwrap();
|
||||||
|
assert!(body.contains("From: Sender <sender@test.com>"));
|
||||||
|
assert!(body.contains("Content-Type: text/html"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_sender_error() {
|
||||||
|
let mut cfg = test_config();
|
||||||
|
cfg.from_email = None;
|
||||||
|
let req = req_with_to("to@test.com");
|
||||||
|
let err = build_message_from_parts(&cfg, &req).unwrap_err();
|
||||||
|
assert!(matches!(err, EmailError::MissingSender));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_recipients_error() {
|
||||||
|
let req = SendEmailRequest {
|
||||||
|
from: Some(EmailAddress {
|
||||||
|
email: "x@x.com".into(),
|
||||||
|
name: String::new(),
|
||||||
|
}),
|
||||||
|
to: vec![],
|
||||||
|
cc: vec![],
|
||||||
|
bcc: vec![],
|
||||||
|
subject: "s".into(),
|
||||||
|
text_body: "b".into(),
|
||||||
|
html_body: String::new(),
|
||||||
|
attachments: vec![],
|
||||||
|
priority: 0,
|
||||||
|
headers: Default::default(),
|
||||||
|
reply_to: String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = build_message_from_parts(&test_config(), &req).unwrap_err();
|
||||||
|
assert!(matches!(err, EmailError::MissingRecipients));
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
use emailks::{
|
||||||
|
error::EmailError,
|
||||||
|
pb::email::v1::{EmailAddress, SendEmailRequest},
|
||||||
|
queue::EmailQueue,
|
||||||
|
};
|
||||||
|
use std::sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicUsize, Ordering},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn dummy_request() -> SendEmailRequest {
|
||||||
|
SendEmailRequest {
|
||||||
|
from: None,
|
||||||
|
to: vec![],
|
||||||
|
cc: vec![],
|
||||||
|
bcc: vec![],
|
||||||
|
subject: "test".into(),
|
||||||
|
text_body: "body".into(),
|
||||||
|
html_body: String::new(),
|
||||||
|
attachments: vec![],
|
||||||
|
priority: 0,
|
||||||
|
headers: Default::default(),
|
||||||
|
reply_to: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn enqueue_and_consume_success() {
|
||||||
|
let (queue, worker) = EmailQueue::unbounded();
|
||||||
|
let counter = Arc::new(AtomicUsize::new(0));
|
||||||
|
let c = counter.clone();
|
||||||
|
|
||||||
|
let mut req = dummy_request();
|
||||||
|
req.to.push(EmailAddress {
|
||||||
|
email: "test@example.com".into(),
|
||||||
|
name: String::new(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let id = queue.enqueue(req).expect("enqueue should work");
|
||||||
|
assert!(id > 0);
|
||||||
|
|
||||||
|
worker.spawn(move |_job| {
|
||||||
|
let c = c.clone();
|
||||||
|
async move {
|
||||||
|
c.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Ok::<(), EmailError>(())
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for async consumption
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
assert_eq!(counter.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn retry_then_succeed() {
|
||||||
|
let (queue, worker) = EmailQueue::unbounded();
|
||||||
|
let attempts = Arc::new(AtomicUsize::new(0));
|
||||||
|
let a = attempts.clone();
|
||||||
|
|
||||||
|
let _id = queue.enqueue(dummy_request()).unwrap();
|
||||||
|
|
||||||
|
worker.spawn(move |_job| {
|
||||||
|
let a = a.clone();
|
||||||
|
async move {
|
||||||
|
let n = a.fetch_add(1, Ordering::SeqCst);
|
||||||
|
if n < 2 {
|
||||||
|
Err(EmailError::Send("temp failure".into()))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(700)).await;
|
||||||
|
assert_eq!(attempts.load(Ordering::SeqCst), 3); // 2 fails + 1 success
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn terminal_error_destroyed_immediately() {
|
||||||
|
let (queue, worker) = EmailQueue::unbounded();
|
||||||
|
let store = queue.status_store().clone();
|
||||||
|
let attempts = Arc::new(AtomicUsize::new(0));
|
||||||
|
let a = attempts.clone();
|
||||||
|
|
||||||
|
let id = queue.enqueue(dummy_request()).unwrap();
|
||||||
|
|
||||||
|
worker.spawn(move |_job| {
|
||||||
|
let a = a.clone();
|
||||||
|
async move {
|
||||||
|
a.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Err(EmailError::MissingRecipients)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
assert_eq!(attempts.load(Ordering::SeqCst), 1);
|
||||||
|
|
||||||
|
let entry = store.get(id).expect("should have status entry");
|
||||||
|
assert_eq!(entry.status, emailks::pb::email::v1::SendStatus::Failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bounded_channel_blocks_when_full() {
|
||||||
|
let (queue, _worker) = EmailQueue::bounded(1);
|
||||||
|
let _id1 = queue.enqueue(dummy_request()).unwrap();
|
||||||
|
// Second enqueue should fail with Full
|
||||||
|
let err = queue.enqueue(dummy_request()).unwrap_err();
|
||||||
|
assert!(matches!(err, emailks::error::QueueError::Full));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn status_store_tracks_lifecycle() {
|
||||||
|
let (queue, worker) = EmailQueue::unbounded();
|
||||||
|
let store = queue.status_store().clone();
|
||||||
|
|
||||||
|
let id = queue.enqueue(dummy_request()).unwrap();
|
||||||
|
// Status should be Queued
|
||||||
|
let entry = store.get(id).unwrap();
|
||||||
|
assert_eq!(entry.status, emailks::pb::email::v1::SendStatus::Queued);
|
||||||
|
|
||||||
|
worker.spawn(move |_job| async move { Ok::<(), EmailError>(()) });
|
||||||
|
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
let entry = store.get(id).unwrap();
|
||||||
|
assert_eq!(entry.status, emailks::pb::email::v1::SendStatus::Sent);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user