//! Holzleitner Mail-Versende-Client (Windows). //! //! Läuft entweder als **Windows-Dienst** (vom SCM gestartet) oder im //! **Konsolenmodus** (interaktiv / zum Testen). In beiden Fällen pollt er alle //! `poll_interval_secs` (Default 300 = 5 Min): //! 1. GET {backend}/admin/delivered-belegnummern (offene, noch nicht //! versendete Belege; Header X-Admin-Api-Key) //! 2. Wenn nicht leer: EIN Aufruf von ERPFRAME.EXE mit allen Belegnummern //! (Format 'V-1','V-2') — ERPframe verschickt die Mails. //! 3. Nur bei ExitCode 0: POST {backend}/admin/mark-mail-sent → markiert die //! Belege server-seitig als versendet (Dedup). //! //! Erst ERPframe, dann markieren ⇒ schlägt ERPframe fehl, bleiben die Belege //! offen und werden beim nächsten Tick erneut versucht. mod config; mod logging; #[cfg(windows)] mod service; use std::path::{Path, PathBuf}; use std::time::Duration; use anyhow::{anyhow, Context, Result}; use serde::Deserialize; use crate::config::Config; use crate::logging::Logger; #[derive(Debug, Deserialize)] struct DeliveredResponse { #[serde(default)] belegnummern: Vec, } #[derive(Debug, Deserialize)] struct MarkResponse { #[serde(default)] marked: u64, } /// Verzeichnis der laufenden EXE. Wichtig, weil ein Dienst mit Arbeits- /// verzeichnis `C:\Windows\System32` startet — config.json/logs werden daher /// IMMER relativ zur EXE aufgelöst, nicht relativ zum cwd. fn exe_dir() -> PathBuf { std::env::current_exe() .ok() .and_then(|p| p.parent().map(|d| d.to_path_buf())) .unwrap_or_else(|| PathBuf::from(".")) } fn main() { // Unter Windows: standardmäßig versuchen, als Dienst zu starten. Wird die // EXE interaktiv gestartet (Doppelklick / Konsole), scheitert der // SCM-Dispatcher (nicht vom SCM gestartet) → Konsolenmodus. `--console` // erzwingt den Konsolenmodus direkt. #[cfg(windows)] { let console = std::env::args().any(|a| a == "--console" || a == "-c"); if !console { match service::run() { Ok(()) => return, // lief als Dienst, sauber beendet Err(_e) => { /* interaktiv gestartet → Konsole */ } } } } run_console(); } /// Konsolenmodus: eigene tokio-Runtime, Stop via Ctrl-C. fn run_console() { let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() .expect("tokio runtime"); rt.block_on(run_app(async { let _ = tokio::signal::ctrl_c().await; })); } /// Lädt Konfiguration + Logger und fährt die Poll-Schleife, bis `shutdown` /// feuert (Ctrl-C im Konsolenmodus, Stop/Shutdown vom SCM im Dienstmodus). /// Gibt selbst keine Fehler zurück — alles wird geloggt (im Dienst gibt es /// keine Konsole; vor dem Logger greift `fallback_log`). pub(crate) async fn run_app(shutdown: impl std::future::Future) { let base_dir = exe_dir(); let cfg = match Config::load(&base_dir.join("config.json")) { Ok(c) => c, Err(e) => { fallback_log(&base_dir, &format!("Konfiguration konnte nicht geladen werden: {e:#}")); return; } }; let log_dir = if Path::new(&cfg.log_dir).is_absolute() { PathBuf::from(&cfg.log_dir) } else { base_dir.join(&cfg.log_dir) }; let logger = match Logger::new(&log_dir) { Ok(l) => l, Err(e) => { fallback_log(&base_dir, &format!("Log-Verzeichnis konnte nicht angelegt werden: {e:#}")); return; } }; logger.info(&format!( "Mailclient gestartet. backend={} intervall={}s erpframe={}", cfg.backend_base_url, cfg.poll_interval_secs, cfg.erpframe.exe_path )); let client = match reqwest::Client::builder() .timeout(Duration::from_secs(cfg.request_timeout_secs)) .build() { Ok(c) => c, Err(e) => { logger.error(&format!("HTTP-Client konnte nicht gebaut werden: {e:#}")); return; } }; let mut ticker = tokio::time::interval(Duration::from_secs(cfg.poll_interval_secs)); tokio::pin!(shutdown); loop { tokio::select! { _ = ticker.tick() => { if let Err(e) = run_tick(&cfg, &client, &logger).await { logger.error(&format!("Durchlauf fehlgeschlagen: {e:#}")); } } _ = &mut shutdown => { logger.info("Beende (Stop-Signal)."); break; } } } } /// Notfall-Logging, wenn der reguläre Logger (noch) nicht steht — schreibt nach /// `\mailclient-fatal.log`. Im Dienstmodus die einzige Spur, falls /// schon das Laden von config.json scheitert. fn fallback_log(dir: &Path, msg: &str) { eprintln!("{msg}"); let file = dir.join("mailclient-fatal.log"); if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&file) { use std::io::Write; let _ = writeln!( f, "[{}] {}", chrono::Local::now().format("%Y-%m-%d %H:%M:%S"), msg ); } } /// Ein Poll-Durchlauf. Fehler werden zurückgegeben (vom Aufrufer geloggt) und /// brechen die Schleife NICHT ab. async fn run_tick(cfg: &Config, client: &reqwest::Client, logger: &Logger) -> Result<()> { let base = cfg.backend_base_url.trim_end_matches('/'); // 1) Offene Belege holen (ohne day ⇒ alle offenen). let url = format!("{base}/admin/delivered-belegnummern"); let resp = client .get(&url) .header("X-Admin-Api-Key", &cfg.admin_api_key) .send() .await .context("GET delivered-belegnummern")?; let status = resp.status(); if !status.is_success() { let body = resp.text().await.unwrap_or_default(); return Err(anyhow!("delivered-belegnummern HTTP {status}: {body}")); } let data: DeliveredResponse = resp.json().await.context("Antwort parsen")?; let belege = data.belegnummern; if belege.is_empty() { logger.info("Keine offenen Belege."); return Ok(()); } let liste = belege.join(", "); logger.info(&format!("{} offene Beleg(e) gefunden: {}", belege.len(), liste)); // 2) ERPframe aufrufen (ein Aufruf, alle Belege). Fehler ⇒ NICHT markieren, // Belege bleiben offen und werden beim nächsten Tick erneut versucht. if let Err(e) = run_erpframe(cfg, logger, &belege).await { logger.error(&format!( "ERPframe-Versand fehlgeschlagen — {} Beleg(e) bleiben offen [{}]: {e:#}", belege.len(), liste )); return Ok(()); } // 3) Server-seitig als versendet markieren. let mark_url = format!("{base}/admin/mark-mail-sent"); let result = client .post(&mark_url) .header("X-Admin-Api-Key", &cfg.admin_api_key) .json(&serde_json::json!({ "belegnummern": belege })) .send() .await; match result { Ok(resp) if resp.status().is_success() => { let marked = resp .json::() .await .map(|m| m.marked) .unwrap_or(0); // DAS ist die zentrale „erfolgreich versendet"-Zeile: logger.info(&format!( "VERSENDET: {} Beleg(e) verschickt + als versendet markiert: [{}] (frisch markiert: {})", belege.len(), liste, marked )); } Ok(resp) => { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); // Mails sind raus, aber Markierung schlug fehl → Doppelversand-Risiko. logger.error(&format!( "ACHTUNG: ERPframe-Versand OK, aber mark-mail-sent HTTP {status} — Belege [{}] \ bleiben offen und werden beim nächsten Tick ERNEUT versendet! Antwort: {body}", liste )); } Err(e) => { logger.error(&format!( "ACHTUNG: ERPframe-Versand OK, aber mark-mail-sent nicht erreichbar — Belege [{}] \ bleiben offen und werden beim nächsten Tick ERNEUT versendet! Fehler: {e:#}", liste )); } } Ok(()) } /// Startet ERPFRAME.EXE synchron (ein Aufruf, alle Belege als ein Argument) /// und wartet auf das Ende. Argumente wie im PowerShell-Skript: /// ERPFRAME.EXE /// Belege im Format 'V-1','V-2' (jede Nr. single-quoted, kommagetrennt). async fn run_erpframe(cfg: &Config, logger: &Logger, belege: &[String]) -> Result<()> { let e = &cfg.erpframe; // Jede Belegnummer in einfache Anführungszeichen, mit dem konfigurierten // Trenner verbunden → 'V-30690288','V-30690290' (Format, das das // ERPframe-Makro erwartet, direkt für SQL `IN (...)` verwendbar). let belege_csv = belege .iter() .map(|b| format!("'{b}'")) .collect::>() .join(&cfg.belegnummer_separator); let mut cmd = tokio::process::Command::new(&e.exe_path); cmd.arg(&e.mode) .arg(&e.app_name) .arg(&e.user) .arg(&e.password) .arg(&e.command) .arg(&belege_csv); // Arbeitsverzeichnis: konfiguriert, sonst EXE-Verzeichnis. if let Some(wd) = &e.working_dir { cmd.current_dir(wd); } else if let Some(parent) = Path::new(&e.exe_path).parent() { if !parent.as_os_str().is_empty() { cmd.current_dir(parent); } } logger.info(&format!( "Starte ERPframe: {} {} \"{}\" \"{}\" \"***\" \"{}\" \"{}\"", e.exe_path, e.mode, e.app_name, e.user, e.command, belege_csv )); let mut child = cmd .spawn() .with_context(|| format!("ERPframe starten: {}", e.exe_path))?; let status = if cfg.erp_timeout_secs > 0 { match tokio::time::timeout(Duration::from_secs(cfg.erp_timeout_secs), child.wait()).await { Ok(s) => s.context("ERPframe abwarten")?, Err(_) => { let _ = child.kill().await; return Err(anyhow!( "ERPframe Timeout nach {}s — Prozess beendet", cfg.erp_timeout_secs )); } } } else { child.wait().await.context("ERPframe abwarten")? }; if status.success() { logger.info("ERPframe erfolgreich (ExitCode 0)."); Ok(()) } else { Err(anyhow!("ERPframe ExitCode {:?}", status.code())) } }