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:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal 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
1603
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
Cargo.toml
Normal file
27
Cargo.toml
Normal 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
108
README.md
Normal 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
18
config.example.json
Normal 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
80
install-service.ps1
Normal 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
89
src/config.rs
Normal 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
45
src/logging.rs
Normal 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
307
src/main.rs
Normal 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
97
src/service.rs
Normal 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
20
uninstall-service.ps1
Normal 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."
|
||||||
Reference in New Issue
Block a user