Files
Infrastruktur-Monitoring-Sy…/backend/crates/application/src/settings_service.rs
Dennis Nemec fa9ac9a6bc
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / ui (push) Has been cancelled
WP-02: encrypted settings, SMTP mail, job runner and cron scheduler
AES-256-GCM secret storage keyed by MASTER_KEY, SMTP settings with test mail
(lettre), persisted job runs with log and status, JobRunner with per-kind
concurrency guard, 6-field cron schedules with defaults, scheduler loop.
Settings and Jobs pages in the UI. FAKE_HOST mode for dev machines.

Tests: 30 application, 8 infrastructure, 23 API, 16 Vitest, 6 Playwright.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:16:56 +02:00

110 lines
3.3 KiB
Rust

use std::sync::Arc;
use domain::jobs::JobKind;
use domain::ports::{Cipher, Mailer, SettingsRepository};
use domain::settings::{SmtpSettings, KEY_SMTP, SECRET_KEYS};
use domain::DomainError;
use crate::scheduler::validate_cron;
pub struct SettingsService {
repo: Arc<dyn SettingsRepository>,
cipher: Arc<dyn Cipher>,
mailer: Arc<dyn Mailer>,
}
impl SettingsService {
pub fn new(
repo: Arc<dyn SettingsRepository>,
cipher: Arc<dyn Cipher>,
mailer: Arc<dyn Mailer>,
) -> Self {
Self {
repo,
cipher,
mailer,
}
}
async fn get(&self, key: &str) -> Result<Option<String>, DomainError> {
let Some(raw) = self.repo.get(key).await? else {
return Ok(None);
};
if SECRET_KEYS.contains(&key) {
self.cipher.decrypt(&raw).map(Some)
} else {
Ok(Some(raw))
}
}
async fn set(&self, key: &str, value: &str) -> Result<(), DomainError> {
let stored = if SECRET_KEYS.contains(&key) {
self.cipher.encrypt(value)?
} else {
value.to_string()
};
self.repo.set(key, &stored).await
}
pub async fn smtp(&self) -> Result<Option<SmtpSettings>, DomainError> {
match self.get(KEY_SMTP).await? {
Some(json) => serde_json::from_str(&json)
.map(Some)
.map_err(|e| DomainError::Storage(e.to_string())),
None => Ok(None),
}
}
pub async fn set_smtp(&self, smtp: SmtpSettings) -> Result<(), DomainError> {
smtp.validate()?;
let json = serde_json::to_string(&smtp).map_err(|e| DomainError::Storage(e.to_string()))?;
self.set(KEY_SMTP, &json).await
}
/// Send a mail to the configured recipients (or `to` if given) using the stored SMTP settings.
pub async fn send_mail(
&self,
to: Option<Vec<String>>,
subject: &str,
body: &str,
) -> Result<(), DomainError> {
let smtp = self
.smtp()
.await?
.ok_or_else(|| DomainError::Validation("smtp is not configured".into()))?;
let to = to.unwrap_or_else(|| smtp.notify_to.clone());
if to.is_empty() {
return Err(DomainError::Validation("no recipients configured".into()));
}
self.mailer.send(&smtp, &to, subject, body).await
}
/// Cron expression (6 fields, seconds first) for a scheduled job kind, or None if disabled.
pub async fn schedule(&self, kind: JobKind) -> Result<Option<String>, DomainError> {
match self.get(&schedule_key(kind)).await? {
Some(v) if v.is_empty() => Ok(None),
Some(v) => Ok(Some(v)),
None => Ok(kind.default_schedule().map(String::from)),
}
}
pub async fn set_schedule(
&self,
kind: JobKind,
cron: Option<String>,
) -> Result<(), DomainError> {
let value = match cron.map(|c| c.trim().to_string()).filter(|c| !c.is_empty()) {
Some(c) => {
validate_cron(&c)?;
c
}
None => String::new(),
};
self.set(&schedule_key(kind), &value).await
}
}
fn schedule_key(kind: JobKind) -> String {
format!("schedule.{}", kind.as_str())
}