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
+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);
}