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>
This commit is contained in:
Dennis Nemec
2026-06-01 18:02:07 +02:00
commit 3774a378f3
11 changed files with 2399 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
/target
# enthält Secrets (API-Key, ERPframe-Passwort) — nicht committen
/config.json
/logs
.DS_Store

1603
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

27
Cargo.toml Normal file
View File

@ -0,0 +1,27 @@
[package]
name = "holzleitner-mailclient"
version = "0.1.0"
edition = "2021"
description = "Pollt die noch nicht versendeten Belegnummern vom Holzleitner-Backend und stößt ERPFRAME.EXE an (das die Mails verschickt). Läuft als langlaufender Prozess unter Windows."
[[bin]]
name = "holzleitner-mailclient"
path = "src/main.rs"
[dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "signal", "process"] }
# rustls statt OpenSSL → keine C/OpenSSL-Abhängigkeit, einfacher für Windows-Builds.
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = "0.4"
anyhow = "1"
# Windows-Dienst-Integration (SCM). Nur unter Windows kompiliert; auf anderen
# Plattformen (z. B. Mac für den Kompiliertest) wird das Modul ausgeblendet.
[target.'cfg(windows)'.dependencies]
windows-service = "0.6"
[profile.release]
strip = true
lto = true

108
README.md Normal file
View File

@ -0,0 +1,108 @@
# Holzleitner Mail-Versende-Client
Langlaufender Windows-Client. Pollt alle 5 Minuten die noch **nicht versendeten**
Belegnummern vom Backend und stößt **ERPFRAME.EXE** an, das die Mails verschickt.
## Ablauf je Tick (Default alle 300 s)
1. `GET {backend}/admin/delivered-belegnummern` — Header `X-Admin-Api-Key`.
Liefert nur Belege, deren Liefermail noch offen ist (`mail_sent_at IS NULL`).
2. Sind welche offen: **ein** Aufruf
`ERPFRAME.EXE <mode> <app_name> <user> <password> <command> <belege_csv>`
(alle Belegnummern kommagetrennt als ein Argument) und auf Ende warten.
3. **Nur bei ExitCode 0**: `POST {backend}/admin/mark-mail-sent` mit den
Belegnummern → markiert sie server-seitig als versendet (Dedup).
Reihenfolge **erst ERPframe, dann markieren**: schlägt ERPframe fehl, bleiben
die Belege offen und werden beim nächsten Tick erneut versucht. Alle Fehler
werden geloggt, die Schleife läuft weiter.
## Konfiguration
`config.json` **neben der EXE** (Vorlage: `config.example.json`). Enthält
Secrets → nicht ins Git (siehe `.gitignore`).
| Feld | Bedeutung |
|---|---|
| `backend_base_url` | z. B. `http://10.168.10.2:3000` (ohne `/admin`) |
| `admin_api_key` | Wert für Header `X-Admin-Api-Key` |
| `poll_interval_secs` | Poll-Intervall (Default 300) |
| `request_timeout_secs` | HTTP-Timeout je Request (Default 30) |
| `erp_timeout_secs` | Timeout für den ERPframe-Prozess; 0 = unbegrenzt (Default 600) |
| `belegnummer_separator` | Trennzeichen für das Belege-Argument (Default `,`) |
| `log_dir` | Log-Verzeichnis (relativ ⇒ relativ zur EXE; Default `logs`) |
| `erpframe.exe_path` | voller Pfad zu `ERPFRAME.EXE` |
| `erpframe.mode` | erstes Argument (Default `AUTOSTARTUP`) |
| `erpframe.app_name` / `user` / `password` / `command` | wie im PowerShell-Skript |
| `erpframe.working_dir` | Arbeitsverzeichnis (`null` ⇒ EXE-Verzeichnis) |
Logs: `<log_dir>/mailclient_YYYY-MM-DD.log` (+ stdout).
## Build
Das Programm nutzt **rustls** (kein OpenSSL) → keine C-Abhängigkeiten.
### Auf einem Windows-Rechner (empfohlen, MSVC)
```
cargo build --release
```
`target\release\holzleitner-mailclient.exe`
### Cross-Compile vom Mac/Linux (GNU-Target)
```
rustup target add x86_64-pc-windows-gnu
brew install mingw-w64 # macOS; Linux: apt install gcc-mingw-w64
cargo build --release --target x86_64-pc-windows-gnu
```
`target/x86_64-pc-windows-gnu/release/holzleitner-mailclient.exe`
### In einer Windows-VM auf Apple Silicon (ARM64)
Die VM ist **ARM64** — ein einfaches `cargo build` erzeugt eine ARM64-EXE, die
auf dem **x64**-Server NICHT läuft. Immer explizit x64 bauen:
```
rustup target add x86_64-pc-windows-msvc
cargo build --release --target x86_64-pc-windows-msvc
```
**Nicht auf einem Shared-Drive (`Z:`) bauen** (Build bricht mit `os error 87` ab,
weil das Shared-FS die Temp-Datei-Operationen nicht unterstützt) — die Quelle
darf dort liegen, aber `target/` auf lokales NTFS legen:
```
$env:CARGO_TARGET_DIR = "C:\cargo-target\mailclient"
```
## Deployment als Windows-Dienst
Die EXE ist **dienstfähig** — sie spricht direkt mit dem Service Control Manager
(sauberes Start/Stop, Autostart, Auto-Restart bei Absturz).
1. EXE + `config.json` in ein Verzeichnis legen, z. B. `C:\Holzleitner\Mailclient\`.
`config.json` muss **neben der EXE** liegen (der Dienst startet mit
Arbeitsverzeichnis `C:\Windows\System32`).
2. PowerShell **als Administrator** öffnen und registrieren:
```powershell
cd C:\Holzleitner\Mailclient
.\install-service.ps1
# optional unter einem bestimmten Konto (z. B. für ERPframe-DB-/Netzzugriff):
.\install-service.ps1 -Credential (Get-Credential)
```
Registriert den Dienst **„Holzleitner App Mails"** (interner Name
`HolzleitnerAppMails`), verzögerter Autostart, Auto-Restart, und startet ihn.
3. Verwalten — in `services.msc` erscheint er als **„Holzleitner App Mails"**:
```powershell
Get-Service HolzleitnerAppMails
Stop-Service HolzleitnerAppMails
Start-Service HolzleitnerAppMails
```
4. Entfernen:
```powershell
.\uninstall-service.ps1
```
### Konsolenmodus (zum Testen)
Direkter Start (Doppelklick / Konsole) läuft automatisch im Konsolenmodus
(der SCM-Dispatcher schlägt fehl → Fallback). Erzwingen mit:
```powershell
.\holzleitner-mailclient.exe --console
```
Beenden mit Ctrl-C (sauberer Shutdown). Da die Binary selbst alle 5 Min pollt,
**keinen** zusätzlichen 5-Minuten-Task einrichten — ein Dauerprozess genügt.

18
config.example.json Normal file
View File

@ -0,0 +1,18 @@
{
"backend_base_url": "http://10.168.10.2:3000",
"admin_api_key": "<admin-api-key>",
"poll_interval_secs": 300,
"request_timeout_secs": 30,
"erp_timeout_secs": 600,
"belegnummer_separator": ",",
"log_dir": "logs",
"erpframe": {
"exe_path": "C:\\Program Files\\GSD\\ERPframe\\ERPFRAME.EXE",
"mode": "AUTOSTARTUP",
"app_name": "HOLZ_SQL_TEST_APP",
"user": "SYSTEM",
"password": "GEHEIM",
"command": "_SV_MAIL_VERSAND",
"working_dir": null
}
}

80
install-service.ps1 Normal file
View File

@ -0,0 +1,80 @@
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Registriert den Holzleitner-Mailclient als Windows-Dienst "Holzleitner App Mails".
.DESCRIPTION
Muss als Administrator ausgeführt werden. Erwartet die kompilierte EXE
(Default: neben diesem Skript) und eine config.json IM SELBEN Verzeichnis
wie die EXE (der Dienst startet mit Arbeitsverzeichnis C:\Windows\System32,
daher werden config.json/logs relativ zur EXE aufgelöst).
.PARAMETER ExePath
Pfad zu holzleitner-mailclient.exe. Default: neben diesem Skript.
.PARAMETER Credential
Optionales Dienstkonto (z. B. der ERPframe-/Domänen-Benutzer). Ohne Angabe
läuft der Dienst als LocalSystem. Für ERPframe ist oft ein echtes
Benutzerkonto nötig (DB-/Netzwerkzugriff). Das Konto braucht das Recht
"Als Dienst anmelden".
.EXAMPLE
.\install-service.ps1
.EXAMPLE
.\install-service.ps1 -ExePath "C:\HolzleitnerMail\holzleitner-mailclient.exe" -Credential (Get-Credential)
#>
[CmdletBinding()]
param(
[string]$ExePath,
[string]$ServiceName = "HolzleitnerAppMails",
[string]$DisplayName = "Holzleitner App Mails",
[pscredential]$Credential
)
$ErrorActionPreference = 'Stop'
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
if (-not $ExePath) { $ExePath = Join-Path $ScriptDir 'holzleitner-mailclient.exe' }
if (-not (Test-Path $ExePath)) { throw "EXE nicht gefunden: $ExePath" }
$ExePath = (Resolve-Path $ExePath).Path
$ExeDir = Split-Path $ExePath -Parent
if (-not (Test-Path (Join-Path $ExeDir 'config.json'))) {
Write-Warning "config.json fehlt neben der EXE ($ExeDir) - der Dienst startet sonst nicht korrekt."
}
# Vorhandenen Dienst stoppen & entfernen (Neuinstallation)
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($existing) {
Write-Host "Dienst '$ServiceName' existiert bereits - stoppe & entferne..."
if ($existing.Status -ne 'Stopped') { Stop-Service -Name $ServiceName -Force }
& sc.exe delete $ServiceName | Out-Null
Start-Sleep -Seconds 2
}
# binPath in doppelten Quotes (Pfad kann Leerzeichen enthalten)
$bin = "`"$ExePath`""
$params = @{
Name = $ServiceName
DisplayName = $DisplayName
BinaryPathName = $bin
StartupType = 'Automatic'
Description = 'Pollt offene Belegnummern vom Holzleitner-Backend und stoesst ERPframe zum Mailversand an (alle 5 Min).'
}
if ($Credential) { $params['Credential'] = $Credential }
Write-Host "Registriere Dienst '$DisplayName' ($ServiceName) -> $ExePath"
New-Service @params | Out-Null
# Auto-Restart bei Absturz: 3x mit 60s Abstand, Fehlerzaehler nach 1 Tag zuruecksetzen
& sc.exe failure $ServiceName reset= 86400 actions= restart/60000/restart/60000/restart/60000 | Out-Null
# Verzoegerter Autostart (startet nach den System-Diensten)
& sc.exe config $ServiceName start= delayed-auto | Out-Null
Write-Host "Starte Dienst..."
Start-Service -Name $ServiceName
Start-Sleep -Seconds 1
Get-Service -Name $ServiceName | Format-List Name, DisplayName, Status, StartType
Write-Host "Logs: $(Join-Path $ExeDir 'logs')"
Write-Host "Fertig."

89
src/config.rs Normal file
View File

@ -0,0 +1,89 @@
//! Konfiguration aus `config.json` (liegt neben der EXE).
use std::path::Path;
use anyhow::{Context, Result};
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct Config {
/// Basis-URL des Backends, z. B. `http://10.168.10.2:3000` (ohne /admin).
pub backend_base_url: String,
/// Wert für den Header `X-Admin-Api-Key`.
pub admin_api_key: String,
/// Poll-Intervall in Sekunden (Default 300 = 5 Min).
#[serde(default = "d_poll")]
pub poll_interval_secs: u64,
/// Timeout je HTTP-Request in Sekunden (Default 30).
#[serde(default = "d_timeout")]
pub request_timeout_secs: u64,
/// Timeout für den ERPframe-Prozess in Sekunden; 0 = unbegrenzt warten
/// (Default 600 = 10 Min). Verhindert, dass ein hängender ERPframe-Aufruf
/// die Schleife dauerhaft blockiert.
#[serde(default = "d_erp_timeout")]
pub erp_timeout_secs: u64,
/// Trennzeichen, mit dem die Belegnummern zu EINEM ERPframe-Argument
/// verbunden werden (Default ",").
#[serde(default = "d_sep")]
pub belegnummer_separator: String,
/// Log-Verzeichnis; relativ ⇒ relativ zum EXE-Verzeichnis (Default "logs").
#[serde(default = "d_logdir")]
pub log_dir: String,
pub erpframe: ErpframeConfig,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ErpframeConfig {
/// Voller Pfad zu ERPFRAME.EXE.
pub exe_path: String,
/// Erstes Argument (wie im PowerShell-Skript), Default "AUTOSTARTUP".
#[serde(default = "d_mode")]
pub mode: String,
pub app_name: String,
pub user: String,
pub password: String,
/// Das ERPframe-Kommando/Makro, das die Mails versendet.
pub command: String,
/// Arbeitsverzeichnis; `null` ⇒ EXE-Verzeichnis.
#[serde(default)]
pub working_dir: Option<String>,
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("Datei lesen: {}", path.display()))?;
let cfg: Config = serde_json::from_str(&raw).context("config.json parsen")?;
if cfg.backend_base_url.trim().is_empty() {
anyhow::bail!("backend_base_url ist leer");
}
if cfg.admin_api_key.trim().is_empty() {
anyhow::bail!("admin_api_key ist leer");
}
if cfg.erpframe.exe_path.trim().is_empty() {
anyhow::bail!("erpframe.exe_path ist leer");
}
Ok(cfg)
}
}
fn d_poll() -> u64 {
300
}
fn d_timeout() -> u64 {
30
}
fn d_erp_timeout() -> u64 {
600
}
fn d_sep() -> String {
",".into()
}
fn d_logdir() -> String {
"logs".into()
}
fn d_mode() -> String {
"AUTOSTARTUP".into()
}

45
src/logging.rs Normal file
View File

@ -0,0 +1,45 @@
//! Schlanker Datei-Logger: schreibt zeitgestempelte Zeilen in ein Tageslog
//! (`<dir>/mailclient_YYYY-MM-DD.log`) UND auf stdout — analog zum
//! PowerShell-Skript, ohne zusätzliche Logging-Crates.
use std::fs;
use std::io::Write;
use std::path::PathBuf;
pub struct Logger {
dir: PathBuf,
}
impl Logger {
pub fn new(dir: impl Into<PathBuf>) -> std::io::Result<Self> {
let dir = dir.into();
fs::create_dir_all(&dir)?;
Ok(Self { dir })
}
fn write(&self, level: &str, msg: &str) {
let now = chrono::Local::now();
let line = format!(
"[{}] [{}] {}",
now.format("%Y-%m-%d %H:%M:%S"),
level,
msg
);
println!("{line}");
let file = self.dir.join(format!("mailclient_{}.log", now.format("%Y-%m-%d")));
if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(&file) {
let _ = writeln!(f, "{line}");
}
}
pub fn info(&self, msg: &str) {
self.write("INFO", msg);
}
#[allow(dead_code)] // Teil der Logger-API, aktuell nicht genutzt
pub fn warn(&self, msg: &str) {
self.write("WARN", msg);
}
pub fn error(&self, msg: &str) {
self.write("ERROR", msg);
}
}

307
src/main.rs Normal file
View File

@ -0,0 +1,307 @@
//! 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()))
}
}

97
src/service.rs Normal file
View File

@ -0,0 +1,97 @@
//! Windows-Dienst-Integration (Service Control Manager). Nur unter Windows
//! kompiliert (`#[cfg(windows)]` am `mod service` in `main.rs`).
//!
//! Ablauf: `run()` übergibt den Prozess an den SCM. Der SCM ruft `service_main`
//! in einem eigenen Thread; dort registrieren wir einen Control-Handler
//! (für Stop/Shutdown), melden „Running", starten eine eigene tokio-Runtime und
//! lassen `crate::run_app` laufen, bis der Handler ein Stop-Signal schickt.
use std::ffi::OsString;
use std::sync::mpsc;
use std::time::Duration;
use windows_service::service::{
ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus, ServiceType,
};
use windows_service::service_control_handler::{self, ServiceControlHandlerResult};
use windows_service::{define_windows_service, service_dispatcher};
/// Interner Dienst-Name (Schlüssel). **Muss** mit dem `-Name` im
/// Install-Skript (`install-service.ps1`) übereinstimmen. Der Anzeigename
/// „Holzleitner App Mails" wird separat beim Registrieren gesetzt.
const SERVICE_NAME: &str = "HolzleitnerAppMails";
const SERVICE_TYPE: ServiceType = ServiceType::OWN_PROCESS;
define_windows_service!(ffi_service_main, service_main);
/// Übergibt den Prozess an den SCM. Gibt `Err` zurück, wenn der Prozess NICHT
/// vom SCM gestartet wurde (interaktiver Start) — der Aufrufer fällt dann in
/// den Konsolenmodus zurück.
pub fn run() -> windows_service::Result<()> {
service_dispatcher::start(SERVICE_NAME, ffi_service_main)
}
fn service_main(_args: Vec<OsString>) {
// Fehler hier landen (falls vor dem Logger) im fallback_log von run_app bzw.
// werden vom SCM als nicht-laufend erkannt.
let _ = run_service();
}
fn run_service() -> windows_service::Result<()> {
// Kanal: Control-Handler -> Loop. Stop/Shutdown sendet ein () hinein.
let (shutdown_tx, shutdown_rx) = mpsc::channel::<()>();
let handler = move |control| -> ServiceControlHandlerResult {
match control {
ServiceControl::Stop | ServiceControl::Shutdown => {
let _ = shutdown_tx.send(());
ServiceControlHandlerResult::NoError
}
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
_ => ServiceControlHandlerResult::NotImplemented,
}
};
let status_handle = service_control_handler::register(SERVICE_NAME, handler)?;
// „Running" melden (akzeptiert Stop + System-Shutdown).
status_handle.set_service_status(ServiceStatus {
service_type: SERVICE_TYPE,
current_state: ServiceState::Running,
controls_accepted: ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
})?;
// Eigene tokio-Runtime im Dienst-Thread (kein #[tokio::main]).
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("tokio runtime");
rt.block_on(async move {
// mpsc::recv ist blockierend → in spawn_blocking auslagern und als
// Future an run_app übergeben.
let shutdown = async move {
let _ = tokio::task::spawn_blocking(move || {
let _ = shutdown_rx.recv();
})
.await;
};
crate::run_app(shutdown).await;
});
// „Stopped" melden.
status_handle.set_service_status(ServiceStatus {
service_type: SERVICE_TYPE,
current_state: ServiceState::Stopped,
controls_accepted: ServiceControlAccept::empty(),
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
})?;
Ok(())
}

20
uninstall-service.ps1 Normal file
View File

@ -0,0 +1,20 @@
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Stoppt und entfernt den Dienst "Holzleitner App Mails".
#>
[CmdletBinding()]
param(
[string]$ServiceName = "HolzleitnerAppMails"
)
$ErrorActionPreference = 'Stop'
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if (-not $svc) { Write-Host "Dienst '$ServiceName' existiert nicht."; return }
if ($svc.Status -ne 'Stopped') {
Write-Host "Stoppe Dienst..."
Stop-Service -Name $ServiceName -Force
}
& sc.exe delete $ServiceName | Out-Null
Write-Host "Dienst '$ServiceName' entfernt."