use crate::admin_api::AdminAppState; use crate::bark_push::{read_bark_push_config, send_bark_push, BarkPushConfig}; use crate::email_delivery::{ read_smtp_delivery_config, send_smtp_email, ComposedEmail, SmtpDeliveryConfig, }; use crate::handlers::shared::{system_config_bool, system_config_string}; use crate::server_chan_push::{ read_server_chan_push_config, send_server_chan_push, ServerChanPushConfig, }; use crate::{AppState, GatewayError}; use axum::body::Bytes; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use tracing::warn; pub(crate) const IMPORTANT_NOTIFICATION_ENABLED_KEY: &str = "module.important_notification.enabled"; pub(crate) const LEGACY_NOTIFICATION_EMAIL_ENABLED_KEY: &str = "module.notification_email.enabled"; pub(crate) const IMPORTANT_NOTIFICATION_EMAIL_ENABLED_KEY: &str = "module.important_notification.email_enabled"; pub(crate) const IMPORTANT_NOTIFICATION_EMAIL_RECIPIENTS_KEY: &str = "module.important_notification.email_recipients"; pub(crate) const IMPORTANT_NOTIFICATION_DEFAULT_CHANNEL_KEY: &str = "module.important_notification.default_channel"; pub(crate) const IMPORTANT_NOTIFICATION_ITEMS_KEY: &str = "module.important_notification.items"; pub(crate) const PROVIDER_QUOTA_ALERT_ITEM_KEY: &str = "provider_quota_alert"; // Notification configuration is administrator-controlled but is also read on // request paths. Bound fan-out and template materialization so a malformed or // compromised configuration cannot trigger unbounded work or SMTP payloads. const MAX_NOTIFICATION_RECIPIENTS: usize = 128; const MAX_NOTIFICATION_RECIPIENT_BYTES: usize = 320; const MAX_NOTIFICATION_RECIPIENT_VALUE_BYTES: usize = 64 * 1024; const MAX_NOTIFICATION_RECIPIENT_PARTS_PER_VALUE: usize = 256; const MAX_NOTIFICATION_ITEMS: usize = 128; const MAX_NOTIFICATION_ITEM_KEY_BYTES: usize = 128; const MAX_NOTIFICATION_ITEM_NAME_BYTES: usize = 512; const MAX_NOTIFICATION_TEMPLATE_BYTES: usize = 256 * 1024; #[derive(Debug, Clone)] pub(crate) struct ImportantNotification { pub(crate) title: String, pub(crate) markdown_body: String, pub(crate) text_body: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ImportantNotificationChannelFilter { All, Email, ServerChan, Bark, } #[derive(Debug, Clone)] struct ImportantNotificationConfig { module_enabled: bool, email_enabled: bool, email_recipients: Vec, default_channel: ImportantNotificationChannelFilter, items: Vec, server_chan: ServerChanPushConfig, bark: BarkPushConfig, } #[derive(Debug, Clone)] struct ImportantNotificationItemConfig { key: String, name: String, enabled: bool, channel: Option, title_template: Option, markdown_template: Option, text_template: Option, user_email_enabled: bool, } #[derive(Debug, Clone, Copy)] struct NotificationChannelReadiness { email: bool, server_chan: bool, bark: bool, } #[derive(Debug, Clone, Serialize)] pub(crate) struct ImportantNotificationChannelReport { pub(crate) channel: &'static str, pub(crate) success: bool, pub(crate) message: String, } #[derive(Debug, Clone, Serialize)] pub(crate) struct ImportantNotificationDeliveryReport { pub(crate) success: bool, pub(crate) channels: Vec, } #[derive(Debug, Deserialize)] struct ImportantNotificationTestRequest { #[serde(default)] channel: Option, #[serde(default)] item_key: Option, } pub(crate) async fn important_notification_module_enabled( state: &AppState, ) -> Result { let canonical = state .read_system_config_json_value(IMPORTANT_NOTIFICATION_ENABLED_KEY) .await?; if canonical.is_some() { return Ok(system_config_bool(canonical.as_ref(), false)); } let legacy = state .read_system_config_json_value(LEGACY_NOTIFICATION_EMAIL_ENABLED_KEY) .await?; Ok(system_config_bool(legacy.as_ref(), false)) } pub(crate) async fn important_notification_configured( state: &AppState, ) -> Result { let config = read_important_notification_config(state).await?; important_notification_has_configured_channel(state, &config).await } pub(crate) async fn important_notification_dispatch_ready_for_item( state: &AppState, item_key: &str, ) -> Result { let config = read_important_notification_config(state).await?; if !config.module_enabled { return Ok(false); } if let Some(item) = find_notification_item(&config, item_key) { if !item.enabled { return Ok(false); } } let readiness = read_notification_channel_readiness(state, &config).await?; Ok(channel_filter_has_ready_channel( notification_item_channel_filter(&config, item_key), readiness, )) } pub(crate) async fn send_important_notification( state: &AppState, notification: ImportantNotification, ) -> Result { send_important_notification_with_filter( state, notification, ImportantNotificationChannelFilter::All, ) .await } pub(crate) async fn send_important_notification_for_item( state: &AppState, item_key: &str, notification: ImportantNotification, variables: &[(&str, String)], ) -> Result { dispatch_important_notification(state, Some(item_key), notification, variables, None, false) .await } pub(crate) async fn send_important_notification_with_filter( state: &AppState, notification: ImportantNotification, channel_filter: ImportantNotificationChannelFilter, ) -> Result { dispatch_important_notification(state, None, notification, &[], Some(channel_filter), false) .await } pub(crate) async fn send_user_important_notification_email( state: &AppState, item_key: &str, user_email: &str, notification: ImportantNotification, variables: &[(&str, String)], ) -> Result { let config = read_important_notification_config(state).await?; if !config.module_enabled { return Ok(single_report("module", false, "通知服务未启用")); } let Some(item) = find_notification_item(&config, item_key) else { return Ok(single_report("item", false, "通知项未定义")); }; if !item.enabled { return Ok(single_report("item", false, "通知项未启用")); } if !item.user_email_enabled { return Ok(single_report("user_email", false, "通知项未启用用户邮件")); } let notification = apply_notification_item_template(Some(item), notification, variables); let smtp_config = match read_smtp_delivery_config(state).await? { Some(config) => config, None => return Ok(single_report("user_email", false, "SMTP 配置不完整")), }; let user_email = user_email.trim(); if user_email.is_empty() { return Ok(single_report("user_email", false, "用户邮箱为空")); } match send_single_email_notification(smtp_config, user_email, ¬ification).await { Ok(()) => Ok(single_report("user_email", true, "用户邮件通知已发送")), Err(err) => { warn!(error = ?err, user_email = %user_email, "failed to send user notification email"); Ok(single_report( "user_email", false, format!("用户邮件通知发送失败: {err:?}"), )) } } } async fn important_notification_has_configured_channel( state: &AppState, config: &ImportantNotificationConfig, ) -> Result { let readiness = read_notification_channel_readiness(state, config).await?; if channel_filter_has_ready_channel(config.default_channel, readiness) { return Ok(true); } Ok(config.items.iter().any(|item| { item.enabled && channel_filter_has_ready_channel( item.channel.unwrap_or(config.default_channel), readiness, ) })) } async fn read_notification_channel_readiness( state: &AppState, config: &ImportantNotificationConfig, ) -> Result { let email = config.email_enabled && !config.email_recipients.is_empty() && matches!(read_smtp_delivery_config(state).await, Ok(Some(_))); Ok(NotificationChannelReadiness { email, server_chan: config.server_chan.enabled && config.server_chan.send_key.is_some(), bark: config.bark.enabled && config.bark.device_key.is_some(), }) } fn channel_filter_has_ready_channel( filter: ImportantNotificationChannelFilter, readiness: NotificationChannelReadiness, ) -> bool { match filter { ImportantNotificationChannelFilter::All => { readiness.email || readiness.server_chan || readiness.bark } ImportantNotificationChannelFilter::Email => readiness.email, ImportantNotificationChannelFilter::ServerChan => readiness.server_chan, ImportantNotificationChannelFilter::Bark => readiness.bark, } } async fn dispatch_important_notification( state: &AppState, item_key: Option<&str>, notification: ImportantNotification, variables: &[(&str, String)], channel_override: Option, bypass_enable_checks: bool, ) -> Result { let config = read_important_notification_config(state).await?; if !bypass_enable_checks && !config.module_enabled { return Ok(single_report("module", false, "通知服务未启用")); } let item = item_key.and_then(|key| find_notification_item(&config, key)); if !bypass_enable_checks && item.is_some_and(|item| !item.enabled) { return Ok(single_report("item", false, "通知项未启用")); } let notification = apply_notification_item_template(item, notification, variables); let channel_filter = channel_override.unwrap_or_else(|| { item.and_then(|item| item.channel) .unwrap_or(config.default_channel) }); let mut reports = Vec::new(); if matches!( channel_filter, ImportantNotificationChannelFilter::All | ImportantNotificationChannelFilter::Email ) { maybe_send_email_notification( state, &config, ¬ification, bypass_enable_checks, &mut reports, ) .await; } if matches!( channel_filter, ImportantNotificationChannelFilter::All | ImportantNotificationChannelFilter::ServerChan ) { maybe_send_server_chan_notification( state, &config, ¬ification, bypass_enable_checks, &mut reports, ) .await; } if matches!( channel_filter, ImportantNotificationChannelFilter::All | ImportantNotificationChannelFilter::Bark ) { maybe_send_bark_notification( state, &config, ¬ification, bypass_enable_checks, &mut reports, ) .await; } if reports.is_empty() { reports.push(ImportantNotificationChannelReport { channel: "none", success: false, message: "未启用可用的通知通道".to_string(), }); } let success = reports.iter().any(|report| report.success); Ok(ImportantNotificationDeliveryReport { success, channels: reports, }) } pub(crate) async fn build_important_notification_test_payload( state: &AdminAppState<'_>, request_body: Option<&Bytes>, ) -> Result { let request = match request_body.filter(|body| !body.is_empty()) { Some(body) => serde_json::from_slice::(body).unwrap_or( ImportantNotificationTestRequest { channel: None, item_key: None, }, ), None => ImportantNotificationTestRequest { channel: None, item_key: None, }, }; let filter = request.channel.as_deref().and_then(parse_channel_filter); let item_key = request .item_key .as_deref() .map(str::trim) .filter(|value| !value.is_empty()); let variables = vec![ ("provider_name", "示例 Provider".to_string()), ("provider_id", "provider-demo".to_string()), ("total_available", "8.0000".to_string()), ("threshold_amount", "10.0000".to_string()), ("user_email", "user@example.com".to_string()), ("balance", "1.0000".to_string()), ]; let report = dispatch_important_notification( state.app(), item_key, ImportantNotification { title: "Aether 通知服务测试".to_string(), markdown_body: "这是一条来自 Aether 的通知服务测试。".to_string(), text_body: "这是一条来自 Aether 的通知服务测试。".to_string(), }, &variables, filter, true, ) .await?; Ok(json!({ "success": report.success, "message": if report.success { "测试通知已发送" } else { "测试通知发送失败" }, "channels": report.channels, })) } async fn read_important_notification_config( state: &AppState, ) -> Result { let module_enabled = important_notification_module_enabled(state).await?; let email_enabled = state .read_system_config_json_value(IMPORTANT_NOTIFICATION_EMAIL_ENABLED_KEY) .await?; let email_recipients = state .read_system_config_json_value(IMPORTANT_NOTIFICATION_EMAIL_RECIPIENTS_KEY) .await?; let default_channel = state .read_system_config_json_value(IMPORTANT_NOTIFICATION_DEFAULT_CHANNEL_KEY) .await?; let items = state .read_system_config_json_value(IMPORTANT_NOTIFICATION_ITEMS_KEY) .await?; Ok(ImportantNotificationConfig { module_enabled, email_enabled: system_config_bool(email_enabled.as_ref(), false), email_recipients: parse_recipient_list(email_recipients.as_ref()), default_channel: default_channel .as_ref() .and_then(|value| value.as_str()) .and_then(parse_channel_filter) .unwrap_or(ImportantNotificationChannelFilter::All), items: parse_notification_items(items.as_ref()), server_chan: read_server_chan_push_config(state).await?, bark: read_bark_push_config(state).await?, }) } fn parse_channel_filter(raw: &str) -> Option { match raw.trim().to_ascii_lowercase().as_str() { "all" => Some(ImportantNotificationChannelFilter::All), "email" => Some(ImportantNotificationChannelFilter::Email), "server_chan" | "serverchan" | "serve_chan" => { Some(ImportantNotificationChannelFilter::ServerChan) } "bark" => Some(ImportantNotificationChannelFilter::Bark), "global" | "" => None, _ => None, } } fn parse_notification_items(value: Option<&Value>) -> Vec { let Some(Value::Array(items)) = value else { return default_notification_items(); }; items .iter() .take(MAX_NOTIFICATION_ITEMS) .filter_map(parse_notification_item) .collect::>() } fn parse_notification_item(value: &Value) -> Option { let item = value.as_object()?; let key = item.get("key")?.as_str()?.trim(); if key.is_empty() || key.len() > MAX_NOTIFICATION_ITEM_KEY_BYTES { return None; } let name = item .get("name") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .filter(|value| value.len() <= MAX_NOTIFICATION_ITEM_NAME_BYTES) .unwrap_or(key); Some(ImportantNotificationItemConfig { key: key.to_string(), name: name.to_string(), enabled: item.get("enabled").and_then(Value::as_bool).unwrap_or(true), channel: item .get("channel") .and_then(Value::as_str) .and_then(parse_channel_filter), title_template: bounded_optional_string(item.get("title_template")), markdown_template: bounded_optional_string(item.get("markdown_template")), text_template: bounded_optional_string(item.get("text_template")), user_email_enabled: item .get("user_email_enabled") .and_then(Value::as_bool) .unwrap_or(false), }) } fn default_notification_items() -> Vec { vec![ ImportantNotificationItemConfig { key: PROVIDER_QUOTA_ALERT_ITEM_KEY.to_string(), name: "号池额度不足".to_string(), enabled: true, channel: None, title_template: None, markdown_template: None, text_template: None, user_email_enabled: false, }, ImportantNotificationItemConfig { key: "provider_pool_abnormal".to_string(), name: "号池异常".to_string(), enabled: true, channel: None, title_template: Some("号池异常:{provider_name}".to_string()), markdown_template: Some( "号池 `{provider_name}` 出现异常,请检查服务状态。".to_string(), ), text_template: Some("号池 {provider_name} 出现异常,请检查服务状态。".to_string()), user_email_enabled: false, }, ImportantNotificationItemConfig { key: "user_balance_low".to_string(), name: "用户余额不足".to_string(), enabled: true, channel: Some(ImportantNotificationChannelFilter::Email), title_template: Some("余额不足提醒".to_string()), markdown_template: Some("你的账户余额已低于提醒阈值,请及时处理。".to_string()), text_template: Some("你的账户余额已低于提醒阈值,请及时处理。".to_string()), user_email_enabled: true, }, ] } fn optional_non_empty_string(value: Option<&Value>) -> Option { value .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } fn bounded_optional_string(value: Option<&Value>) -> Option { optional_non_empty_string(value).filter(|value| value.len() <= MAX_NOTIFICATION_TEMPLATE_BYTES) } fn find_notification_item<'a>( config: &'a ImportantNotificationConfig, item_key: &str, ) -> Option<&'a ImportantNotificationItemConfig> { let item_key = item_key.trim(); config.items.iter().find(|item| item.key == item_key) } fn notification_item_channel_filter( config: &ImportantNotificationConfig, item_key: &str, ) -> ImportantNotificationChannelFilter { find_notification_item(config, item_key) .and_then(|item| item.channel) .unwrap_or(config.default_channel) } fn apply_notification_item_template( item: Option<&ImportantNotificationItemConfig>, notification: ImportantNotification, variables: &[(&str, String)], ) -> ImportantNotification { let Some(item) = item else { return notification; }; let title = render_template( item.title_template.as_deref(), ¬ification.title, ¬ification, variables, ); let markdown_body = render_template( item.markdown_template.as_deref(), ¬ification.markdown_body, ¬ification, variables, ); let text_body = render_template( item.text_template.as_deref(), ¬ification.text_body, ¬ification, variables, ); ImportantNotification { title, markdown_body, text_body, } } fn render_template( template: Option<&str>, fallback: &str, notification: &ImportantNotification, variables: &[(&str, String)], ) -> String { let mut rendered = template .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(fallback) .to_string(); rendered = rendered .replace("{title}", ¬ification.title) .replace("{body}", ¬ification.markdown_body) .replace("{text_body}", ¬ification.text_body); for (key, value) in variables { rendered = rendered.replace(&format!("{{{}}}", key.trim()), value); } rendered } async fn maybe_send_email_notification( state: &AppState, config: &ImportantNotificationConfig, notification: &ImportantNotification, bypass_channel_toggle: bool, reports: &mut Vec, ) { if !bypass_channel_toggle && !config.email_enabled { return; } if config.email_recipients.is_empty() { reports.push(ImportantNotificationChannelReport { channel: "email", success: false, message: "未配置邮件收件人".to_string(), }); return; } let smtp_config = match read_smtp_delivery_config(state).await { Ok(Some(config)) => config, Ok(None) => { reports.push(ImportantNotificationChannelReport { channel: "email", success: false, message: "SMTP 配置不完整".to_string(), }); return; } Err(err) => { reports.push(ImportantNotificationChannelReport { channel: "email", success: false, message: format!("读取 SMTP 配置失败: {err:?}"), }); return; } }; let mut sent = 0usize; let mut failed = 0usize; for recipient in &config.email_recipients { match send_single_email_notification(smtp_config.clone(), recipient, notification).await { Ok(()) => sent += 1, Err(err) => { failed += 1; warn!( error = ?err, recipient = %recipient, "failed to send important notification email" ); } } } reports.push(ImportantNotificationChannelReport { channel: "email", success: sent > 0, message: if failed == 0 { format!("邮件通知已发送给 {sent} 个收件人") } else { format!("邮件通知成功 {sent} 个,失败 {failed} 个") }, }); } async fn send_single_email_notification( smtp_config: SmtpDeliveryConfig, recipient: &str, notification: &ImportantNotification, ) -> Result<(), GatewayError> { send_smtp_email( smtp_config, ComposedEmail { to_email: recipient.to_string(), subject: notification.title.clone(), html_body: build_notification_html(notification), text_body: notification.text_body.clone(), }, ) .await } async fn maybe_send_server_chan_notification( state: &AppState, config: &ImportantNotificationConfig, notification: &ImportantNotification, bypass_channel_toggle: bool, reports: &mut Vec, ) { if !bypass_channel_toggle && !config.server_chan.enabled { return; } if config.server_chan.send_key.is_none() { reports.push(ImportantNotificationChannelReport { channel: "server_chan", success: false, message: "未配置 Server 酱 SendKey".to_string(), }); return; }; match send_server_chan_push( state, &config.server_chan, ¬ification.title, ¬ification.markdown_body, ) .await { Ok(()) => reports.push(ImportantNotificationChannelReport { channel: "server_chan", success: true, message: "Server 酱通知已发送".to_string(), }), Err(err) => { warn!(error = ?err, "failed to send server chan important notification"); reports.push(ImportantNotificationChannelReport { channel: "server_chan", success: false, message: format!("Server 酱通知发送失败: {err:?}"), }); } } } async fn maybe_send_bark_notification( state: &AppState, config: &ImportantNotificationConfig, notification: &ImportantNotification, bypass_channel_toggle: bool, reports: &mut Vec, ) { if !bypass_channel_toggle && !config.bark.enabled { return; } if config.bark.device_key.is_none() { reports.push(ImportantNotificationChannelReport { channel: "bark", success: false, message: "未配置 Bark Device Key".to_string(), }); return; }; match send_bark_push( state, &config.bark, ¬ification.title, ¬ification.markdown_body, ) .await { Ok(()) => reports.push(ImportantNotificationChannelReport { channel: "bark", success: true, message: "Bark 通知已发送".to_string(), }), Err(err) => { warn!(error = ?err, "failed to send bark important notification"); reports.push(ImportantNotificationChannelReport { channel: "bark", success: false, message: format!("Bark 通知发送失败: {err:?}"), }); } } } fn single_report( channel: &'static str, success: bool, message: impl Into, ) -> ImportantNotificationDeliveryReport { ImportantNotificationDeliveryReport { success, channels: vec![ImportantNotificationChannelReport { channel, success, message: message.into(), }], } } fn parse_recipient_list(value: Option<&Value>) -> Vec { let mut recipients = Vec::new(); match value { Some(Value::Array(items)) => { for item in items.iter().take(MAX_NOTIFICATION_RECIPIENTS * 4) { if recipients.len() >= MAX_NOTIFICATION_RECIPIENTS { break; } if let Some(raw) = item.as_str() { push_recipient_parts(&mut recipients, raw); } } } Some(Value::String(raw)) => push_recipient_parts(&mut recipients, raw), _ => {} } recipients.sort(); recipients.dedup(); recipients } fn push_recipient_parts(recipients: &mut Vec, raw: &str) { if raw.len() > MAX_NOTIFICATION_RECIPIENT_VALUE_BYTES { return; } for item in raw .split([',', ';', '\n', '\r']) .map(str::trim) .filter(|value| !value.is_empty()) .take(MAX_NOTIFICATION_RECIPIENT_PARTS_PER_VALUE) { if recipients.len() >= MAX_NOTIFICATION_RECIPIENTS { return; } if item.len() > MAX_NOTIFICATION_RECIPIENT_BYTES { continue; } recipients.push(item.to_string()); } } fn build_notification_html(notification: &ImportantNotification) -> String { format!( "

{}

{}
", escape_html(¬ification.title), escape_html(¬ification.text_body), ) } fn escape_html(value: &str) -> String { value .replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } #[cfg(test)] mod tests { use super::{ apply_notification_item_template, important_notification_configured, parse_channel_filter, parse_notification_items, parse_recipient_list, ImportantNotification, ImportantNotificationChannelFilter, IMPORTANT_NOTIFICATION_EMAIL_ENABLED_KEY, IMPORTANT_NOTIFICATION_EMAIL_RECIPIENTS_KEY, MAX_NOTIFICATION_ITEMS, MAX_NOTIFICATION_RECIPIENTS, MAX_NOTIFICATION_RECIPIENT_BYTES, MAX_NOTIFICATION_TEMPLATE_BYTES, }; use crate::{data::GatewayDataState, AppState}; use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY; use serde_json::json; #[tokio::test] async fn unused_email_channel_does_not_load_or_migrate_smtp_password() { for (email_enabled, recipients) in [(false, "ops@example.com"), (true, "")] { let data = GatewayDataState::disabled() .with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY) .with_system_config_values_for_tests(vec![ ( IMPORTANT_NOTIFICATION_EMAIL_ENABLED_KEY.to_string(), json!(email_enabled), ), ( IMPORTANT_NOTIFICATION_EMAIL_RECIPIENTS_KEY.to_string(), json!(recipients), ), ("smtp_host".to_string(), json!("smtp.example.com")), ("smtp_user".to_string(), json!("ops@example.com")), ("smtp_password".to_string(), json!("unused-smtp-password")), ("smtp_from_email".to_string(), json!("ops@example.com")), ]); let state = AppState::new() .expect("gateway state should build") .with_data_state_for_tests(data); assert!(!important_notification_configured(&state).await.unwrap()); assert_eq!( state .read_system_config_json_value_strong("smtp_password") .await .unwrap(), Some(json!("unused-smtp-password")) ); } } #[test] fn parse_recipient_list_accepts_arrays_and_delimiters() { assert_eq!( parse_recipient_list(Some(&json!([ "ops@example.com, admin@example.com", "ops@example.com" ]))), vec![ "admin@example.com".to_string(), "ops@example.com".to_string() ] ); } #[test] fn parse_recipient_list_bounds_fanout_and_drops_oversized_entries() { let oversized = "x".repeat(MAX_NOTIFICATION_RECIPIENT_BYTES + 1); let many = (0..(MAX_NOTIFICATION_RECIPIENTS + 20)) .map(|index| format!("user{index}@example.com")) .collect::>() .join(","); let recipients = parse_recipient_list(Some(&json!([oversized.clone(), many]))); assert!(recipients.len() <= MAX_NOTIFICATION_RECIPIENTS); assert!(!recipients.iter().any(|value| value == &oversized)); } #[test] fn parse_notification_items_reads_channel_and_user_email_flag() { let items = parse_notification_items(Some(&json!([ { "key": "user_balance_low", "name": "用户余额不足", "enabled": true, "channel": "email", "title_template": "余额提醒", "markdown_template": "{user_email}: {balance}", "user_email_enabled": true } ]))); assert_eq!(items.len(), 1); assert_eq!(items[0].key, "user_balance_low"); assert_eq!( items[0].channel, Some(ImportantNotificationChannelFilter::Email) ); assert!(items[0].user_email_enabled); } #[test] fn parse_notification_items_bounds_templates_and_count() { let oversized = "x".repeat(MAX_NOTIFICATION_TEMPLATE_BYTES + 1); let raw = (0..(MAX_NOTIFICATION_ITEMS + 10)) .map(|index| { json!({ "key": format!("item_{index}"), "title_template": oversized.clone(), }) }) .collect::>(); let items = parse_notification_items(Some(&json!(raw))); assert!(items.len() <= MAX_NOTIFICATION_ITEMS); assert!(items.iter().all(|item| item.title_template.is_none())); } #[test] fn parse_channel_filter_accepts_bark() { assert_eq!( parse_channel_filter("bark"), Some(ImportantNotificationChannelFilter::Bark) ); } #[test] fn item_template_renders_fallback_and_variables() { let items = parse_notification_items(Some(&json!([ { "key": "provider_quota_alert", "name": "号池额度不足", "title_template": "额度提醒:{provider_name}", "markdown_template": "{body}\n剩余:{total_available}", "text_template": "{text_body}\n剩余:{total_available}" } ]))); let rendered = apply_notification_item_template( Some(&items[0]), ImportantNotification { title: "默认标题".to_string(), markdown_body: "默认正文".to_string(), text_body: "默认文本".to_string(), }, &[ ("provider_name", "示例 Provider".to_string()), ("total_available", "8.0000".to_string()), ], ); assert_eq!(rendered.title, "额度提醒:示例 Provider"); assert_eq!(rendered.markdown_body, "默认正文\n剩余:8.0000"); assert_eq!(rendered.text_body, "默认文本\n剩余:8.0000"); } }