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:
zhenyi
2026-06-07 22:46:30 +08:00
commit 5fa7a82548
19 changed files with 3717 additions and 0 deletions
+23
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
/target
.env
.env.local
.idea/
+10
View File
@@ -0,0 +1,10 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
Generated
+1753
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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"
+15
View File
@@ -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(())
}
+214
View File
@@ -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(),
}
}
+40
View File
@@ -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
View File
@@ -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()
}
+147
View File
@@ -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()
}
}
+15
View File
@@ -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"));
}
}
}
+75
View File
@@ -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"),
}
}
+88
View File
@@ -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);
}
+310
View File
@@ -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()
}
}
+226
View File
@@ -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))
}
}
+118
View File
@@ -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);
}
}
+135
View File
@@ -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);
}
+102
View File
@@ -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));
}
+127
View File
@@ -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);
}