use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::Json;
use serde::{Deserialize, Serialize};
#[cfg(test)]
use serde_json::Value;
use crate::error::AppError;
use crate::server::AppState;
#[derive(Debug, Deserialize, Default)]
pub struct EmailHistoryParams {
#[serde(default = "default_history_limit")]
pub limit: usize,
}
fn default_history_limit() -> usize {
100
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SentEmailResponse {
pub id: String,
pub sent_at: String,
pub subject: String,
pub to: String,
pub template_used: Option<String>,
pub message_id: String,
pub html_preview: String,
pub html_full: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body_text: Option<String>,
pub cron_job: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct EmailStatusResponse {
pub configured: bool,
pub email: Option<String>,
pub provider: Option<String>,
pub template_count: usize,
pub total_sent: usize,
}
#[derive(Debug, Deserialize)]
pub struct EmailTestRequest {
#[serde(default)]
pub to: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct TemplateResponse {
pub name: String,
pub preview: String,
pub size: usize,
}
#[derive(Debug, Deserialize)]
pub struct EmailConfigRequest {
pub my_email: String,
#[serde(default = "default_provider")]
pub provider: String,
#[serde(default)]
pub host: Option<String>,
#[serde(default)]
pub port: Option<u16>,
#[serde(default)]
pub user: Option<String>,
pub password: String,
}
fn default_provider() -> String {
"gmail".to_string()
}
macro_rules! email_api {
($state:expr) => {
$state.kernel.email.as_ref().ok_or_else(|| {
AppError::ServiceUnavailable(
"Email subsystem not available. Add [email] enabled = true to config.toml".into(),
)
})
};
}
fn load_sent_records(
state_store: &oxios_kernel::state_store::StateStore,
limit: usize,
full: bool,
) -> Vec<serde_json::Value> {
let sent_dir = state_store.base_path.join("email_sent");
if !sent_dir.exists() {
return Vec::new();
}
let mut records = Vec::new();
let Ok(entries) = std::fs::read_dir(&sent_dir) else {
return records;
};
for entry in entries.flatten() {
if entry.path().extension().is_some_and(|ext| ext == "json") {
match std::fs::read_to_string(entry.path()) {
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
Ok(mut val) => {
if !full {
if let Some(obj) = val.as_object_mut() {
obj.remove("html_full");
obj.remove("body_text");
}
}
records.push(val);
}
Err(e) => {
tracing::warn!(
file = ?entry.path(),
error = %e,
"Skipping corrupted email record"
);
}
},
Err(e) => {
tracing::warn!(
file = ?entry.path(),
error = %e,
"Failed to read email record"
);
}
}
}
}
records.sort_by(|a, b| {
let sa = a.get("sent_at").and_then(|v| v.as_str()).unwrap_or("");
let sb = b.get("sent_at").and_then(|v| v.as_str()).unwrap_or("");
sb.cmp(sa)
});
records.truncate(limit);
records
}
fn load_sent_record(
state_store: &oxios_kernel::state_store::StateStore,
id: &str,
) -> Option<serde_json::Value> {
let sent_dir = state_store.base_path.join("email_sent");
if !sent_dir.exists() {
return None;
}
let Ok(entries) = std::fs::read_dir(&sent_dir) else {
return None;
};
for entry in entries.flatten() {
if entry.path().extension().is_some_and(|ext| ext == "json") {
if let Ok(content) = std::fs::read_to_string(entry.path()) {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
if val.get("id").and_then(|v| v.as_str()) == Some(id) {
return Some(val);
}
}
}
}
}
None
}
pub(crate) async fn handle_email_status(
state: State<Arc<AppState>>,
) -> Result<Json<EmailStatusResponse>, AppError> {
let configured = state.kernel.email.is_some();
let (email, provider, template_count) = if let Some(api) = &state.kernel.email {
let templates = api.list_templates().unwrap_or_default();
(
Some(api.default_to().to_string()),
Some("configured".to_string()),
templates.len(),
)
} else {
(None, None, 0)
};
let total_sent = load_sent_records(state.kernel.state.store(), usize::MAX, false).len();
Ok(Json(EmailStatusResponse {
configured,
email,
provider,
template_count,
total_sent,
}))
}
pub(crate) async fn handle_email_history(
state: State<Arc<AppState>>,
Query(params): Query<EmailHistoryParams>,
) -> Result<Json<serde_json::Value>, AppError> {
let limit = params.limit.min(500);
let records = load_sent_records(state.kernel.state.store(), limit, false);
Ok(Json(serde_json::json!({
"emails": records,
"total": records.len(),
"limit": limit,
})))
}
pub(crate) async fn handle_email_history_detail(
state: State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
let record = load_sent_record(state.kernel.state.store(), &id)
.ok_or_else(|| AppError::NotFound(format!("Email record '{id}' not found")))?;
Ok(Json(record))
}
pub(crate) async fn handle_email_templates(
state: State<Arc<AppState>>,
) -> Result<Json<serde_json::Value>, AppError> {
let api = email_api!(state)?;
let names = api
.list_templates()
.map_err(|e| AppError::Internal(e.to_string()))?;
let mut templates = Vec::new();
for name in &names {
match api.load_template(name) {
Ok(content) => templates.push(TemplateResponse {
name: name.clone(),
preview: content.chars().take(200).collect(),
size: content.len(),
}),
Err(e) => {
tracing::warn!(template = %name, error = %e, "Failed to load template");
}
}
}
Ok(Json(serde_json::json!({
"templates": templates,
})))
}
pub(crate) async fn handle_email_template_get(
state: State<Arc<AppState>>,
Path(name): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
let api = email_api!(state)?;
let content = api
.load_template(&name)
.map_err(|e| AppError::NotFound(e.to_string()))?;
Ok(Json(serde_json::json!({
"name": name,
"content": content,
"size": content.len(),
})))
}
pub(crate) async fn handle_email_test(
state: State<Arc<AppState>>,
body: Option<Json<EmailTestRequest>>,
) -> Result<Json<serde_json::Value>, AppError> {
let api = email_api!(state)?;
let default_to = api.default_to().to_string();
api.test_connection()
.await
.map_err(|e| AppError::Internal(format!("SMTP test failed: {e}")))?;
let requested_to = body.and_then(|Json(b)| b.to);
let recipient = requested_to.unwrap_or_else(|| default_to.clone());
let override_note = if recipient != default_to {
format!(" (requested '{recipient}' ignored in v1, sent to '{default_to}')")
} else {
String::new()
};
Ok(Json(serde_json::json!({
"status": "ok",
"message": format!("Test email sent successfully{override_note}"),
"to": default_to,
})))
}
pub(crate) async fn handle_email_setup(
_state: State<Arc<AppState>>,
Json(body): Json<EmailConfigRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let provider = match body.provider.as_str() {
"resend" => oxios_kernel::email::SmtpProvider::Resend,
"gmail" => oxios_kernel::email::SmtpProvider::Gmail,
"icloud" => oxios_kernel::email::SmtpProvider::Icloud,
"fastmail" => oxios_kernel::email::SmtpProvider::Fastmail,
"custom" => oxios_kernel::email::SmtpProvider::Custom,
_ => {
return Err(AppError::BadRequest(format!(
"Unknown provider: {}",
body.provider
)))
}
};
let config = oxios_kernel::config::EmailConfig {
enabled: true,
my_email: body.my_email.clone(),
provider,
host: body.host.unwrap_or_default(),
port: body.port.unwrap_or(0),
tls: None,
user: body.user.unwrap_or_default(),
secret_ref: "email_smtp".to_string(),
rate_limit_per_hour: 10,
};
let smtp = oxios_kernel::SmtpClient::from_config(&config, &body.password)
.map_err(|e| AppError::BadRequest(format!("SMTP config error: {e}")))?;
smtp.test_connection()
.await
.map_err(|e| AppError::BadRequest(format!("SMTP test failed: {e}")))?;
let token = oxi_sdk::TokenBundle {
access_token: body.password,
refresh_token: None,
token_type: "Bearer".to_string(),
obtained_at: chrono::Utc::now(),
expires_in: 0,
scope: None,
};
oxi_sdk::save_token("email_smtp", &token)
.map_err(|e| AppError::Internal(format!("Failed to save credentials: {e}")))?;
let config_path = oxios_kernel::config::expand_home("~/.oxios/config.toml");
if config_path.exists() {
let _ = append_email_section_to_config(&config_path, &config);
}
Ok(Json(serde_json::json!({
"status": "ok",
"message": "Email configured successfully. Restart oxios to activate.",
"email": body.my_email,
})))
}
fn append_email_section_to_config(
config_path: &std::path::Path,
config: &oxios_kernel::config::EmailConfig,
) -> std::io::Result<()> {
let content = std::fs::read_to_string(config_path)?;
if content.contains("[email]") {
return Ok(());
}
let provider_str = match config.provider {
oxios_kernel::email::SmtpProvider::Resend => "resend",
oxios_kernel::email::SmtpProvider::Gmail => "gmail",
oxios_kernel::email::SmtpProvider::Icloud => "icloud",
oxios_kernel::email::SmtpProvider::Fastmail => "fastmail",
oxios_kernel::email::SmtpProvider::Custom => "custom",
};
let section = format!(
"\n# Email (configured by web UI)\n[email]\nenabled = true\nmy_email = \"{}\"\nprovider = \"{}\"\n",
config.my_email, provider_str
);
std::fs::write(config_path, content + §ion)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn make_store_with_records(
records: &[(&str, &str, &str)],
) -> (tempfile::TempDir, oxios_kernel::state_store::StateStore) {
let tmp = tempfile::tempdir().unwrap();
let sent_dir = tmp.path().join("email_sent");
fs::create_dir_all(&sent_dir).unwrap();
for (id, subject, html) in records {
let record = serde_json::json!({
"id": id,
"sent_at": "2026-06-06T08:00:12+00:00",
"subject": subject,
"to": "me@gmail.com",
"template_used": Value::Null,
"message_id": format!("<{id}@test>"),
"html_preview": html.chars().take(100).collect::<String>(),
"html_full": html,
"body_text": Value::Null,
"cron_job": Value::Null,
});
fs::write(sent_dir.join(format!("{id}.json")), record.to_string()).unwrap();
}
let store = oxios_kernel::state_store::StateStore::new(tmp.path().to_path_buf()).unwrap();
(tmp, store)
}
#[test]
fn test_load_sent_records_empty() {
let tmp = tempfile::tempdir().unwrap();
let store = oxios_kernel::state_store::StateStore::new(tmp.path().to_path_buf()).unwrap();
let records = load_sent_records(&store, 100, false);
assert!(records.is_empty());
}
#[test]
fn test_load_sent_records_limit_and_sort() {
let data = vec![
("a", "First", "<p>A</p>"),
("b", "Second", "<p>B</p>"),
("c", "Third", "<p>C</p>"),
];
let (_tmp, store) = make_store_with_records(&data);
let records = load_sent_records(&store, 100, false);
assert_eq!(records.len(), 3);
}
#[test]
fn test_load_sent_records_strips_full_when_not_requested() {
let data = vec![(
"a",
"Subject",
"<p>very long html body that should be stripped</p>",
)];
let (_tmp, store) = make_store_with_records(&data);
let records = load_sent_records(&store, 100, false);
let rec = &records[0];
assert!(
rec.get("html_full").is_none(),
"html_full should be stripped"
);
assert!(
rec.get("body_text").is_none(),
"body_text should be stripped"
);
assert!(rec.get("html_preview").is_some());
}
#[test]
fn test_load_sent_records_keeps_full_when_requested() {
let data = vec![("a", "Subject", "<p>full body</p>")];
let (_tmp, store) = make_store_with_records(&data);
let records = load_sent_records(&store, 100, true);
let rec = &records[0];
assert_eq!(
rec.get("html_full").and_then(|v| v.as_str()),
Some("<p>full body</p>")
);
}
#[test]
fn test_load_sent_record_exact_id_match() {
let data = vec![
("abc", "Short ID", "<p>abc</p>"),
("abcd1234", "Long ID", "<p>abcd</p>"),
];
let (_tmp, store) = make_store_with_records(&data);
let r1 = load_sent_record(&store, "abc").expect("abc should match");
assert_eq!(r1.get("subject").and_then(|v| v.as_str()), Some("Short ID"));
let r2 = load_sent_record(&store, "abcd1234").expect("abcd1234 should match");
assert_eq!(r2.get("subject").and_then(|v| v.as_str()), Some("Long ID"));
assert!(
load_sent_record(&store, "ab").is_none(),
"ab must not match abc"
);
assert!(
load_sent_record(&store, "abc1").is_none(),
"abc1 must not match abc"
);
}
#[test]
fn test_load_sent_record_handles_corruption() {
let tmp = tempfile::tempdir().unwrap();
let sent_dir = tmp.path().join("email_sent");
fs::create_dir_all(&sent_dir).unwrap();
let good = serde_json::json!({
"id": "good", "sent_at": "2026-06-06T00:00:00+00:00",
"subject": "Good", "to": "x", "message_id": "m",
"html_preview": "", "html_full": "", "template_used": null, "cron_job": null
});
fs::write(sent_dir.join("good.json"), good.to_string()).unwrap();
fs::write(sent_dir.join("corrupt.json"), "NOT VALID JSON {{{").unwrap();
let store = oxios_kernel::state_store::StateStore::new(tmp.path().to_path_buf()).unwrap();
let r = load_sent_record(&store, "good");
assert!(r.is_some());
let records = load_sent_records(&store, 100, false);
assert_eq!(records.len(), 1);
}
#[test]
fn test_append_email_section_creates_block() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("config.toml");
fs::write(&path, "[kernel]\nworkspace = \"/tmp\"").unwrap();
let config = oxios_kernel::config::EmailConfig {
enabled: true,
my_email: "me@gmail.com".to_string(),
provider: oxios_kernel::email::SmtpProvider::Gmail,
host: String::new(),
port: 0,
tls: None,
user: String::new(),
secret_ref: "email_smtp".to_string(),
rate_limit_per_hour: 10,
};
append_email_section_to_config(&path, &config).unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("[email]"));
assert!(content.contains("my_email = \"me@gmail.com\""));
assert!(content.contains("provider = \"gmail\""));
assert!(content.contains("[kernel]"));
}
#[test]
fn test_append_email_section_idempotent() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("config.toml");
let config = oxios_kernel::config::EmailConfig {
enabled: true,
my_email: "me@gmail.com".to_string(),
provider: oxios_kernel::email::SmtpProvider::Gmail,
host: String::new(),
port: 0,
tls: None,
user: String::new(),
secret_ref: "email_smtp".to_string(),
rate_limit_per_hour: 10,
};
fs::write(&path, "[email]\nenabled = true\n").unwrap();
append_email_section_to_config(&path, &config).unwrap();
let content = fs::read_to_string(&path).unwrap();
assert_eq!(content.matches("[email]").count(), 1);
}
}