Skip to content

Commit 030d588

Browse files
committed
rPing: Implement email configuration and notification system [BETA]
- Added email configuration management with JSON file support. - Created EmailService for sending alerts and test emails. - Integrated email settings into the web interface with validation. - Added endpoints for retrieving and updating email configuration. - Implemented email notifications for failed device checks. - Enhanced UI for email settings with variable insertion and recipient management. - Updated styles for better visibility of status indicators in logs.
1 parent 0f7608c commit 030d588

File tree

12 files changed

+1398
-39
lines changed

12 files changed

+1398
-39
lines changed

Cargo.lock

Lines changed: 461 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,15 @@ rocket = { version = "0.5", features = ["json"] }
1313
serde_json = "1.0"
1414
rand = "0.8"
1515
tokio = { version = "1", features = ["full"] }
16-
chrono = "0.4"
16+
chrono = "0.4"
17+
tokio-postgres = { version = "0.7", features = ["with-uuid-1", "with-serde_json-1"] }
18+
uuid = { version = "1.7", features = ["v4", "serde"] }
19+
dotenv = "0.15"
20+
futures = "0.3"
21+
async-trait = "0.1"
22+
thiserror = "1.0"
23+
anyhow = "1.0"
24+
tracing = "0.1"
25+
tracing-subscriber = "0.3"
26+
lettre = { version = "0.10", features = ["tokio1", "tokio1-native-tls"] }
27+
base64 = "0.21"

email_config.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"smtp_server":"smtp.gmail.com","smtp_port":587,"sender_email":"","sender_password":"","recipients":[],"email_subject":"Failed Log Alert - {device_name}","email_body":"Device {device_name} failed at {date} {time}\nPing Status: {ping_status}\nHTTP Status: {http_status}\nBandwidth: {bandwidth}"}

src/email.rs

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
use anyhow::Result;
2+
use lettre::{
3+
transport::smtp::authentication::Credentials,
4+
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
5+
};
6+
use serde::{Deserialize, Serialize};
7+
use std::sync::Arc;
8+
use tokio::sync::RwLock;
9+
use std::fs;
10+
use std::path::Path;
11+
use log::{info, error};
12+
13+
const CONFIG_FILE: &str = "email_config.json";
14+
15+
#[derive(Debug, Serialize, Deserialize, Clone)]
16+
pub struct EmailConfig {
17+
pub smtp_server: String,
18+
pub smtp_port: u16,
19+
pub sender_email: String,
20+
pub sender_password: String,
21+
pub recipients: Vec<String>,
22+
pub email_subject: String,
23+
pub email_body: String,
24+
}
25+
26+
impl Default for EmailConfig {
27+
fn default() -> Self {
28+
Self {
29+
smtp_server: "smtp.gmail.com".to_string(),
30+
smtp_port: 587,
31+
sender_email: String::new(),
32+
sender_password: String::new(),
33+
recipients: Vec::new(),
34+
email_subject: "Failed Log Alert - {device_name}".to_string(),
35+
email_body: "Device {device_name} failed at {date} {time}\nPing Status: {ping_status}\nHTTP Status: {http_status}\nBandwidth: {bandwidth}".to_string(),
36+
}
37+
}
38+
}
39+
40+
pub struct EmailService {
41+
config: Arc<RwLock<EmailConfig>>,
42+
}
43+
44+
impl EmailService {
45+
pub fn new() -> Self {
46+
let initial_config = Self::load_config_from_file()
47+
.unwrap_or_else(|_| {
48+
info!("No email configuration file found, using defaults");
49+
EmailConfig::default()
50+
});
51+
52+
Self {
53+
config: Arc::new(RwLock::new(initial_config)),
54+
}
55+
}
56+
57+
fn load_config_from_file() -> Result<EmailConfig> {
58+
let config_path = Path::new(CONFIG_FILE);
59+
if !config_path.exists() {
60+
return Err(anyhow::anyhow!("Configuration file does not exist"));
61+
}
62+
63+
let config_str = fs::read_to_string(config_path)?;
64+
let config: EmailConfig = serde_json::from_str(&config_str)?;
65+
Ok(config)
66+
}
67+
68+
async fn save_config_to_file(&self) -> Result<()> {
69+
let config = self.config.read().await;
70+
let config_json = serde_json::to_string_pretty(&*config)?;
71+
72+
// Write to a temporary file first to ensure atomic update
73+
let temp_path = format!("{}.tmp", CONFIG_FILE);
74+
fs::write(&temp_path, config_json)?;
75+
76+
// Rename the temp file to the actual config file
77+
fs::rename(&temp_path, CONFIG_FILE)?;
78+
79+
info!("Email configuration saved successfully");
80+
Ok(())
81+
}
82+
83+
pub async fn update_config(&self, new_config: EmailConfig) -> Result<()> {
84+
{
85+
let mut config = self.config.write().await;
86+
*config = new_config;
87+
}
88+
89+
// Save the updated config to file
90+
self.save_config_to_file().await?;
91+
Ok(())
92+
}
93+
94+
pub async fn get_config(&self) -> EmailConfig {
95+
self.config.read().await.clone()
96+
}
97+
98+
pub async fn send_email(&self, device_name: &str, log_data: &LogData) -> Result<()> {
99+
let config = self.config.read().await;
100+
101+
if config.recipients.is_empty() {
102+
return Err(anyhow::anyhow!("No recipients configured"));
103+
}
104+
105+
let subject = config.email_subject
106+
.replace("{device_name}", device_name)
107+
.replace("{date}", &log_data.date)
108+
.replace("{time}", &log_data.time);
109+
110+
let body = config.email_body
111+
.replace("{device_name}", device_name)
112+
.replace("{date}", &log_data.date)
113+
.replace("{time}", &log_data.time)
114+
.replace("{ping_status}", &log_data.ping_status)
115+
.replace("{http_status}", &log_data.http_status)
116+
.replace("{bandwidth}", &log_data.bandwidth);
117+
118+
let mut email_builder = Message::builder()
119+
.from(config.sender_email.parse()?)
120+
.subject(subject)
121+
.header(lettre::message::header::ContentType::TEXT_PLAIN);
122+
123+
// Add all recipients
124+
for recipient in &config.recipients {
125+
email_builder = email_builder.to(recipient.parse()?);
126+
}
127+
128+
let email = email_builder.body(body)?;
129+
130+
let creds = Credentials::new(
131+
config.sender_email.clone(),
132+
config.sender_password.clone(),
133+
);
134+
135+
let mailer = AsyncSmtpTransport::<Tokio1Executor>::relay(&config.smtp_server)?
136+
.port(config.smtp_port)
137+
.credentials(creds)
138+
.build();
139+
140+
mailer.send(email).await?;
141+
info!("Alert email sent to {} recipients for device {}", config.recipients.len(), device_name);
142+
Ok(())
143+
}
144+
145+
pub async fn send_test_email(&self, test_email: &str) -> Result<()> {
146+
let config = self.config.read().await;
147+
148+
if config.sender_email.is_empty() || config.sender_password.is_empty() {
149+
return Err(anyhow::anyhow!("Sender email or password not configured"));
150+
}
151+
152+
let email = Message::builder()
153+
.from(config.sender_email.parse()?)
154+
.to(test_email.parse()?)
155+
.subject("RustPing Test Email")
156+
.header(lettre::message::header::ContentType::TEXT_PLAIN)
157+
.body("This is a test email from RustPing. If you're receiving this, your email configuration is working correctly!".to_string())?;
158+
159+
let creds = Credentials::new(
160+
config.sender_email.clone(),
161+
config.sender_password.clone(),
162+
);
163+
164+
let mailer = AsyncSmtpTransport::<Tokio1Executor>::relay(&config.smtp_server)?
165+
.port(config.smtp_port)
166+
.credentials(creds)
167+
.build();
168+
169+
mailer.send(email).await?;
170+
info!("Test email sent to {}", test_email);
171+
Ok(())
172+
}
173+
}
174+
175+
#[derive(Debug)]
176+
pub struct LogData {
177+
pub date: String,
178+
pub time: String,
179+
pub ping_status: String,
180+
pub http_status: String,
181+
pub bandwidth: String,
182+
}

src/main.rs

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ extern crate rocket;
33

44
mod models;
55
mod sensors;
6+
mod email;
67

78
use rocket::{get, post, delete, put, routes, State, response::Redirect, catch, catchers};
89
use rocket::serde::json::Json;
@@ -27,6 +28,7 @@ use rocket::request::{self, Request, FromRequest};
2728
use rocket::outcome::Outcome;
2829
use serde::Deserialize;
2930
use rocket::serde::Serialize;
31+
use email::EmailService;
3032

3133
async fn process_logs(start_date_parsed: Option<NaiveDate>, end_date_parsed: Option<NaiveDate>) -> Vec<String> {
3234
let mut filtered_logs = Vec::new();
@@ -689,6 +691,56 @@ async fn reload_devices_from_file(file_path: &str, devices: SharedDevices) {
689691

690692
use env_logger;
691693

694+
#[get("/api/email/config")]
695+
async fn get_email_config(email_service: &State<Arc<EmailService>>) -> Json<serde_json::Value> {
696+
let config = email_service.get_config().await;
697+
Json(json!(config))
698+
}
699+
700+
#[post("/api/email/config", data = "<config>")]
701+
async fn update_email_config(
702+
email_service: &State<Arc<EmailService>>,
703+
config: Json<email::EmailConfig>,
704+
) -> Result<Json<serde_json::Value>, Status> {
705+
match email_service.update_config(config.into_inner()).await {
706+
Ok(_) => Ok(Json(json!({
707+
"status": "success",
708+
"message": "Email configuration updated successfully"
709+
}))),
710+
Err(e) => {
711+
error!("Failed to update email config: {}", e);
712+
Err(Status::InternalServerError)
713+
}
714+
}
715+
}
716+
717+
#[derive(Deserialize)]
718+
struct TestEmailRequest {
719+
test_email: String,
720+
}
721+
722+
#[post("/api/email/config/test", data = "<request>")]
723+
async fn send_test_email(
724+
email_service: &State<Arc<EmailService>>,
725+
request: Json<TestEmailRequest>,
726+
) -> Result<Json<serde_json::Value>, Status> {
727+
match email_service.send_test_email(&request.test_email).await {
728+
Ok(_) => Ok(Json(json!({
729+
"status": "success",
730+
"message": "Test email sent successfully"
731+
}))),
732+
Err(e) => {
733+
error!("Failed to send test email: {}", e);
734+
Err(Status::InternalServerError)
735+
}
736+
}
737+
}
738+
739+
#[get("/static/email_config.html")]
740+
async fn email_config_page(_auth: Auth) -> Option<NamedFile> {
741+
NamedFile::open(Path::new("static/email_config.html")).await.ok()
742+
}
743+
692744
#[tokio::main]
693745
async fn main() {
694746
// Initialize logger with debug level
@@ -698,8 +750,11 @@ async fn main() {
698750
info!("Starting RustPing Network Device Monitor...");
699751

700752
let devices: SharedDevices = Arc::new(Mutex::new(Vec::new()));
753+
let email_service = Arc::new(EmailService::new());
754+
701755
let rocket_instance = rocket::build()
702756
.manage(devices.clone())
757+
.manage(email_service.clone())
703758
.mount("/static", FileServer::from(relative!("static")).rank(2))
704759
.mount("/", routes![
705760
index,
@@ -718,6 +773,10 @@ async fn main() {
718773
delete_web_device,
719774
manage_device,
720775
update_device,
776+
get_email_config,
777+
update_email_config,
778+
send_test_email,
779+
email_config_page,
721780
])
722781
.register("/", catchers![unauthorized]);
723782

@@ -737,6 +796,7 @@ async fn main() {
737796

738797
// Spawn a background task.
739798
let devices_clone = devices.clone();
799+
let email_service_clone = email_service.clone();
740800

741801
tokio::spawn(async move {
742802
let mut device_statuses: HashMap<String, DeviceStatus> = HashMap::new();
@@ -823,19 +883,42 @@ async fn main() {
823883
"N/A".to_string()
824884
};
825885

886+
let ping_status_str = status.ping_status.map_or("N/A", |s| if s { "OK" } else { "FAIL" });
887+
826888
let log_entry = format!(
827889
"{} - {} ({}): Ping: {}, HTTP: {}, Bandwidth: {}\n",
828890
now.format("%Y-%m-%d %H:%M:%S"),
829891
dev.name,
830892
dev.ip,
831-
status.ping_status.map_or("N/A", |s| if s { "OK" } else { "FAIL" }),
893+
ping_status_str,
832894
http_status,
833895
bandwidth
834896
);
835897

836898
if let Err(e) = file.write_all(log_entry.as_bytes()) {
837899
error!("Failed to write log entry: {}", e);
838900
}
901+
902+
// Send email notification if ping or HTTP status is FAIL
903+
if ping_status_str == "FAIL" || http_status == "FAIL" {
904+
// Create LogData for email
905+
let log_data = email::LogData {
906+
date: now.format("%Y-%m-%d").to_string(),
907+
time: now.format("%H:%M:%S").to_string(),
908+
ping_status: ping_status_str.to_string(),
909+
http_status: http_status.to_string(),
910+
bandwidth: bandwidth.clone(),
911+
};
912+
913+
// Send email notification in a separate task to avoid blocking
914+
let device_name = dev.name.clone();
915+
let email_service_clone = email_service_clone.clone();
916+
tokio::spawn(async move {
917+
if let Err(e) = email_service_clone.send_email(&device_name, &log_data).await {
918+
error!("Failed to send email notification: {}", e);
919+
}
920+
});
921+
}
839922
}
840923
}
841924
}

0 commit comments

Comments
 (0)