//! Notification *delivery* — the network half of the notify feature. //! //! `openpxe_core::notify` owns the config + persistence; this module //! turns a `NotifyConfig` + a message into an actual delivery: //! //! - Slack / Discord / Teams → HTTP POST of a provider-shaped JSON //! body to the operator's incoming-webhook URL (via `reqwest`). //! - SMTP → a TLS email via `lettre`. //! //! Every send is best-effort and time-bounded: a flaky webhook must //! never wedge a PXE boot. Callers fire these from a detached task. use openpxe_core::{NotifyConfig, NotifyKind}; use std::time::Duration; /// Hard ceiling on any single delivery so a hung endpoint can't pin a /// task forever. const SEND_TIMEOUT: Duration = Duration::from_secs(10); /// Deliver `body` (with an optional `subject`, used as the email /// subject / chat bold-line) using the active provider in `cfg`. /// Returns `Ok(())` on success, or a human-readable error suitable for /// surfacing in the "Send test" response. pub async fn send(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(), String> { if !cfg.is_usable() { return Err("notifications are not enabled / fully configured".into()); } match cfg.kind { NotifyKind::Slack | NotifyKind::Discord | NotifyKind::Teams => { send_webhook(cfg, subject, body).await } NotifyKind::Smtp => send_email(cfg, subject, body).await, } } async fn send_webhook(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(), String> { // Each chat platform wants a different JSON shape for an incoming // webhook. Keep the bodies minimal and plain-text-ish so they // render cleanly everywhere. let combined = if subject.is_empty() { body.to_string() } else { format!("*{subject}*\n{body}") }; let payload = match cfg.kind { NotifyKind::Slack => serde_json::json!({ "text": combined }), NotifyKind::Discord => serde_json::json!({ "content": combined }), NotifyKind::Teams => serde_json::json!({ // Legacy MessageCard — the format every Teams "Incoming // Webhook" connector still accepts. "@type": "MessageCard", "@context": "https://schema.org/extensions", "summary": if subject.is_empty() { "OpenPXE" } else { subject }, "title": subject, "text": body, }), NotifyKind::Smtp => unreachable!("smtp handled separately"), }; let client = reqwest::Client::builder() .timeout(SEND_TIMEOUT) .build() .map_err(|e| format!("could not build HTTP client: {e}"))?; let resp = client .post(&cfg.webhook_url) .json(&payload) .send() .await .map_err(|e| format!("webhook POST failed: {e}"))?; let status = resp.status(); if status.is_success() { Ok(()) } else { let snippet = resp .text() .await .unwrap_or_default() .chars() .take(200) .collect::(); Err(format!("webhook returned HTTP {status}: {snippet}")) } } async fn send_email(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(), String> { use lettre::transport::smtp::authentication::Credentials; use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; let from = if cfg.smtp_from.trim().is_empty() { cfg.smtp_username.trim() } else { cfg.smtp_from.trim() }; if from.is_empty() { return Err("SMTP requires a From address (or a username to fall back to)".into()); } let email = Message::builder() .from( from.parse() .map_err(|e| format!("invalid From address '{from}': {e}"))?, ) .to(cfg .smtp_to .trim() .parse() .map_err(|e| format!("invalid To address '{}': {e}", cfg.smtp_to))?) .subject(if subject.is_empty() { "OpenPXE" } else { subject }) .body(body.to_string()) .map_err(|e| format!("could not build email: {e}"))?; // Implicit TLS (465) vs STARTTLS (587). We never send plaintext. let mut builder = if cfg.smtp_implicit_tls { AsyncSmtpTransport::::relay(&cfg.smtp_host) .map_err(|e| format!("SMTP relay setup failed: {e}"))? } else { AsyncSmtpTransport::::starttls_relay(&cfg.smtp_host) .map_err(|e| format!("SMTP STARTTLS setup failed: {e}"))? } .port(cfg.smtp_port) .timeout(Some(SEND_TIMEOUT)); // Auth is optional — some internal relays accept unauthenticated // mail from trusted hosts. Only attach credentials when a username // is set. if !cfg.smtp_username.trim().is_empty() { builder = builder.credentials(Credentials::new( cfg.smtp_username.trim().to_string(), cfg.smtp_password.clone(), )); } let mailer = builder.build(); mailer .send(email) .await .map(|_| ()) .map_err(|e| format!("SMTP send failed: {e}")) }