Files
Holzleitner-Mail-Client/src/main.rs
Dennis Nemec 3774a378f3 Initial: Holzleitner Mail-Client (Rust, Windows-Service)
Polling-Client, der beim Backend die noch nicht versendeten ausgelieferten
Belege abfragt (GET /admin/delivered-belegnummern), ERPframe per CLI zum
Mailversand anstößt (_SV_MAIL_VERSAND) und die Belege anschließend als
versendet markiert (POST /admin/mark-mail-sent). Authentifizierung gegen
das Backend per X-Admin-Api-Key.

Enthält: Config-Laden (config.json, Vorlage config.example.json), Logging,
Windows-Service-Wrapper (install-/uninstall-service.ps1).

Nicht im Repo (.gitignore): config.json (Secrets: Admin-Key + ERPframe-
Passwort), target/, logs/. config.example.json trägt nur Platzhalter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 18:02:07 +02:00

308 lines
10 KiB
Rust

//! 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<String>,
}
#[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<Output = ()>) {
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
/// `<exe-dir>\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::<MarkResponse>()
.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 <mode> <app_name> <user> <password> <command> <belege>
/// 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::<Vec<_>>()
.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()))
}
}