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>
308 lines
10 KiB
Rust
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()))
|
|
}
|
|
}
|