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>
This commit is contained in:
@ -2,9 +2,11 @@ use std::sync::Arc;
|
||||
|
||||
use domain::jobs::JobKind;
|
||||
use domain::ports::{Cipher, Mailer, SettingsRepository};
|
||||
use domain::settings::SmtpSettings;
|
||||
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>,
|
||||
@ -17,7 +19,6 @@ impl SettingsService {
|
||||
cipher: Arc<dyn Cipher>,
|
||||
mailer: Arc<dyn Mailer>,
|
||||
) -> Self {
|
||||
let _ = (&repo, &cipher, &mailer);
|
||||
Self {
|
||||
repo,
|
||||
cipher,
|
||||
@ -25,34 +26,84 @@ impl SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn smtp(&self) -> Result<Option<SmtpSettings>, DomainError> {
|
||||
todo!()
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_smtp(&self, _smtp: SmtpSettings) -> Result<(), DomainError> {
|
||||
todo!()
|
||||
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,
|
||||
to: Option<Vec<String>>,
|
||||
subject: &str,
|
||||
body: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
todo!()
|
||||
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> {
|
||||
todo!()
|
||||
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>,
|
||||
kind: JobKind,
|
||||
cron: Option<String>,
|
||||
) -> Result<(), DomainError> {
|
||||
todo!()
|
||||
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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user