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