diff --git a/src/main.rs b/src/main.rs index 77e9b9e..aa8f6cd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,7 +27,12 @@ use pages::{ mfa::ManageMfa, }, config::edit::DEFAULT_SETTINGS_URL, - directory::{dns::DnsDisplay, edit::PrincipalEdit, list::PrincipalList}, + directory::{ + dns::DnsDisplay, + edit::PrincipalEdit, + forward::{edit::ForwardEdit, list::ForwardList}, + list::PrincipalList, + }, manage::{ spam::{SpamTest, SpamTrain}, troubleshoot::{TroubleshootDelivery, TroubleshootDmarc}, @@ -76,9 +81,6 @@ pub const STATE_STORAGE_KEY: &str = "webadmin_state"; pub const STATE_LOGIN_NAME_KEY: &str = "webadmin_login_name"; fn main() { - core::schema::print_schemas(&build_schemas()); - return; - _ = console_log::init_with_level(log::Level::Debug); console_error_panic_hook::set_once(); leptos::mount_to_body(|| view! { }) @@ -238,6 +240,28 @@ pub fn App() -> impl IntoView { } /> + + + + }) @@ -708,6 +736,9 @@ impl LayoutBuilder { .create("Accounts") .route("/directory/accounts") .insert(permissions.has_access(Permission::IndividualList)) + .create("Forwards") + .route("/directory/forwards") + .insert(permissions.has_access(Permission::SettingsList)) .create("Groups") .route("/directory/groups") .insert(permissions.has_access(Permission::GroupList)) @@ -735,6 +766,7 @@ impl LayoutBuilder { Permission::MailingListList, Permission::OauthClientList, Permission::ApiKeyList, + Permission::SettingsList, ])) .create("Queues") .icon(view! { }) diff --git a/src/pages/config/mod.rs b/src/pages/config/mod.rs index de4887e..2f8305b 100644 --- a/src/pages/config/mod.rs +++ b/src/pages/config/mod.rs @@ -15,9 +15,9 @@ use crate::{ components::{ form::input::{Duration, Rate}, icon::{ - IconCalendarDays, IconCircleStack, IconCodeBracket, IconHandRaised, IconInbox, - IconInboxArrowDown, IconInboxStack, IconKey, IconServer, IconServerStack, - IconShieldCheck, IconSignal, + IconArrowRightCircle, IconCalendarDays, IconCircleStack, IconCodeBracket, + IconHandRaised, IconInbox, IconInboxArrowDown, IconInboxStack, IconKey, IconServer, + IconServerStack, IconShieldCheck, IconSignal, }, layout::{LayoutBuilder, MenuItem}, }, diff --git a/src/pages/directory/edit.rs b/src/pages/directory/edit.rs index 355f5d8..6acd4b7 100644 --- a/src/pages/directory/edit.rs +++ b/src/pages/directory/edit.rs @@ -44,7 +44,17 @@ use crate::{ }, }; -use super::{build_app_password, parse_app_password, SpecialSecrets}; +use crate::pages::config::UpdateSettings; + +use super::{ + build_app_password, + forward::{ + forward_prefix, list::{parse_forward_settings, rebuild_sieve_for_domains, SettingsList}, + AccountForwardSection, ForwardRule, + }, + parse_app_password, + SpecialSecrets, +}; type PrincipalMap = AHashMap>; @@ -95,6 +105,47 @@ pub fn PrincipalEdit() -> impl IntoView { let data = expect_context::>() .build_form("principals") .into_signal(); + let primary_email = Signal::derive(move || { + data.get().value::("email").unwrap_or_default() + }); + + let forward_to: RwSignal> = create_rw_signal(Vec::new()); + let forward_keep_copy: RwSignal = create_rw_signal(false); + + // Load forward rule whenever the account email changes + let load_forward = create_resource( + move || primary_email.get(), + move |addr| { + let auth = auth.get(); + async move { + if addr.is_empty() || !addr.contains('@') { + return Ok(ForwardRule::default()); + } + let raw = HttpRequest::get("/api/settings/list") + .with_parameter("prefix", format!("mail.forward.{addr}")) + .with_authorization(&auth) + .send::() + .await?; + let mut rule = ForwardRule { from: addr, keep_copy: false, to: Vec::new() }; + for (key, value) in &raw.items { + if key.starts_with("to.") { + rule.to.push(value.clone()); + } else if key == "keep-copy" { + rule.keep_copy = value == "true"; + } + } + rule.to.sort(); + Ok::(rule) + } + }, + ); + create_effect(move |_| { + if let Some(Ok(rule)) = load_forward.get() { + forward_to.set(rule.to); + forward_keep_copy.set(rule.keep_copy); + } + }); + let fetch_principal = create_resource( move || params.get().get("id").cloned().unwrap_or_default(), move |name| { @@ -247,6 +298,9 @@ pub fn PrincipalEdit() -> impl IntoView { let changes = changes.clone(); let auth = auth.get(); let selected_type = selected_type.get(); + let fwd_to = forward_to.get(); + let fwd_keep = forward_keep_copy.get(); + let fwd_email = primary_email.get(); async move { set_pending.set(true); @@ -297,9 +351,62 @@ pub fn PrincipalEdit() -> impl IntoView { result }; + // Also save forwarding rule for Individual accounts + let fwd_result = if selected_type == PrincipalType::Individual + && result.is_ok() + && !fwd_email.is_empty() + && fwd_email.contains('@') + { + use std::collections::HashSet; + let domain = fwd_email.splitn(2, '@').nth(1).unwrap_or("").to_string(); + let affected = HashSet::from([domain]); + let mut updates: Vec = vec![UpdateSettings::Clear { + prefix: format!("{}.", forward_prefix(&fwd_email)), + filter: None, + }]; + let to_addrs: Vec = fwd_to + .iter() + .map(|s| s.trim().to_lowercase()) + .filter(|s| s.contains('@')) + .collect(); + if !to_addrs.is_empty() { + let mut values: Vec<(String, String)> = to_addrs + .iter() + .enumerate() + .map(|(i, addr)| (format!("to.{i}"), addr.clone())) + .collect(); + values.push(("keep-copy".to_string(), fwd_keep.to_string())); + updates.push(UpdateSettings::Insert { + prefix: Some(forward_prefix(&fwd_email)), + values, + assert_empty: false, + }); + } + let fwd_save = HttpRequest::post("/api/settings") + .with_authorization(&auth) + .with_body(updates) + .unwrap() + .send::>() + .await; + if fwd_save.is_ok() { + if let Ok(all_raw) = HttpRequest::get("/api/settings/list") + .with_parameter("prefix", "mail.forward") + .with_authorization(&auth) + .send::() + .await + { + let all_rules = parse_forward_settings(&all_raw.items); + let _ = rebuild_sieve_for_domains(&auth, &affected, &all_rules).await; + } + } + fwd_save.map(|_| ()) + } else { + Ok(()) + }; + set_pending.set(false); - match result { + match result.and(fwd_result) { Ok(_) => { use_navigate()( &format!("/manage/directory/{}", selected_type.resource_name()), @@ -421,6 +528,8 @@ pub fn PrincipalEdit() -> impl IntoView { | PrincipalType::ApiKey ) .then_some("Permissions".to_string()), + matches!(typ, PrincipalType::Individual) + .then_some("Forwarding".to_string()), ] })> @@ -1064,6 +1173,11 @@ pub fn PrincipalEdit() -> impl IntoView { + + } .into_view(), diff --git a/src/pages/directory/forward/edit.rs b/src/pages/directory/forward/edit.rs new file mode 100644 index 0000000..c35a3b5 --- /dev/null +++ b/src/pages/directory/forward/edit.rs @@ -0,0 +1,270 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::collections::HashSet; + +use leptos::*; +use leptos_router::*; + +use serde::Deserialize; + +use crate::{ + components::{ + form::{button::Button, Form, FormButtonBar, FormItem, FormSection}, + messages::alert::{use_alerts, Alert}, + Color, + }, + core::{ + http::{self, HttpRequest}, + oauth::use_authorization, + }, + pages::config::{Settings, UpdateSettings}, +}; + +#[derive(Deserialize, Default)] +struct SettingsList { + pub items: Settings, +} + +use super::{ + forward_prefix, + list::{parse_forward_settings, rebuild_sieve_for_domains}, + ForwardRule, +}; + +#[component] +pub fn ForwardEdit() -> impl IntoView { + let auth = use_authorization(); + let alert = use_alerts(); + let params = use_params_map(); + + let is_create = create_memo(move |_| { + params.get().get("id").map(|id| id == "_new_").unwrap_or(true) + }); + + let from_param = create_memo(move |_| { + if is_create.get() { + String::new() + } else { + params + .get() + .get("id") + .map(|id| id.replace("%40", "@").replace("%2B", "+")) + .unwrap_or_default() + } + }); + + let from = create_rw_signal(String::new()); + let to_input = create_rw_signal(String::new()); // comma/newline separated in textarea + let keep_copy = create_rw_signal(true); + + // Load existing rule when editing + let _load = create_resource( + move || from_param.get(), + move |addr| { + let auth = auth.get(); + async move { + if addr.is_empty() { + return Ok(ForwardRule::default()); + } + let raw = HttpRequest::get("/api/settings/list") + .with_parameter("prefix", format!("mail.forward.{addr}")) + .with_authorization(&auth) + .send::() + .await?; + + let mut rule = ForwardRule { + from: addr.clone(), + keep_copy: true, + to: Vec::new(), + }; + for (key, value) in &raw.items { + if key.starts_with("to.") { + rule.to.push(value.clone()); + } else if key == "keep-copy" { + rule.keep_copy = value == "true"; + } + } + rule.to.sort(); + Ok::(rule) + } + }, + ); + + create_effect(move |_| { + if let Some(Ok(rule)) = _load.get() { + from.set(rule.from.clone()); + to_input.set(rule.to.join("\n")); + keep_copy.set(rule.keep_copy); + } + }); + + let save_action = create_action(move |_: &()| { + let auth = auth.get(); + let from_val = from.get().trim().to_lowercase(); + let to_val: Vec = to_input + .get() + .split([',', '\n', ';']) + .map(|s| s.trim().to_lowercase()) + .filter(|s| s.contains('@')) + .collect(); + let keep = keep_copy.get(); + let old_from = from_param.get(); + let creating = is_create.get(); + + async move { + if from_val.is_empty() || !from_val.contains('@') { + alert.set(Alert::error("From address must be a valid email address.")); + return Ok(()); + } + if to_val.is_empty() { + alert.set(Alert::error( + "At least one valid destination address is required.", + )); + return Ok(()); + } + + let mut updates: Vec = Vec::new(); + let mut affected: HashSet = HashSet::new(); + + if !creating && old_from != from_val { + let old_domain = old_from.splitn(2, '@').nth(1).unwrap_or("").to_string(); + affected.insert(old_domain); + updates.push(UpdateSettings::Clear { + prefix: format!("{}.", forward_prefix(&old_from)), + filter: None, + }); + } + + // Clear existing entries for this address + updates.push(UpdateSettings::Clear { + prefix: format!("{}.", forward_prefix(&from_val)), + filter: None, + }); + + // Insert new values + let mut values: Vec<(String, String)> = to_val + .iter() + .enumerate() + .map(|(i, addr)| (format!("to.{i}"), addr.clone())) + .collect(); + values.push(("keep-copy".to_string(), keep.to_string())); + + updates.push(UpdateSettings::Insert { + prefix: Some(forward_prefix(&from_val)), + values, + assert_empty: false, + }); + + HttpRequest::post("/api/settings") + .with_authorization(&auth) + .with_body(updates)? + .send::>() + .await?; + + // Rebuild Sieve scripts for all affected domains + let new_domain = from_val.splitn(2, '@').nth(1).unwrap_or("").to_string(); + affected.insert(new_domain); + + let all_raw = HttpRequest::get("/api/settings/list") + .with_parameter("prefix", "mail.forward") + .with_authorization(&auth) + .send::() + .await?; + let all_rules = parse_forward_settings(&all_raw.items); + + rebuild_sieve_for_domains(&auth, &affected, &all_rules).await?; + + Ok::<(), http::Error>(()) + } + }); + + create_effect(move |_| { + if let Some(result) = save_action.value().get() { + match result { + Ok(_) => { + alert.set(Alert::success("Forward saved. Sieve script updated.")); + use_navigate()("/manage/directory/forwards", Default::default()); + } + Err(e) => alert.set(Alert::from(e)), + } + } + }); + + view! { +
+ + + + + + +