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,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