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