From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 434A91FF146 for ; Tue, 09 Jun 2026 12:02:41 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 03578B7E2; Tue, 9 Jun 2026 12:02:41 +0200 (CEST) From: Dominik Csapak To: pmg-devel@lists.proxmox.com Subject: [PATCH pmg-yew-quarantine-gui] mail view: show mark seen/unseen depending on mail state Date: Tue, 9 Jun 2026 12:02:22 +0200 Message-ID: <20260609100235.1650066-1-d.csapak@proxmox.com> X-Mailer: git-send-email 2.47.3 MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -1.101 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment KAM_MAILER 2 Automated Mailer Tag Left in Email POISEN_SPAM_PILL 0.1 Meta: its spam POISEN_SPAM_PILL_1 0.1 random spam to be learned in bayes POISEN_SPAM_PILL_3 0.1 random spam to be learned in bayes SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: JRAPNEACEF7NN4TV3MRUOTHN3ZPQPWWR X-Message-ID-Hash: JRAPNEACEF7NN4TV3MRUOTHN3ZPQPWWR X-MailFrom: d.csapak@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox Mail Gateway development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: instead of always trying to show both. The content info was already loaded for the 'load images' checkbox, so reuse that to also fetch the seen status and show the actions depending on that. This has to be repeated whenever the seen state possibly has changed. While at it, refactor the deserialize_flexible_bool helper, and include the variants that were used for the 'external_images' flag. Signed-off-by: Dominik Csapak --- src/main.rs | 15 ++++++ src/page_mail_view.rs | 113 +++++++++++++++++++++++++++--------------- src/spam_list.rs | 14 +----- 3 files changed, 88 insertions(+), 54 deletions(-) diff --git a/src/main.rs b/src/main.rs index 5eacd4d..8646f58 100644 --- a/src/main.rs +++ b/src/main.rs @@ -243,3 +243,18 @@ fn main() { yew::Renderer::::new().render(); } + +// deserialization helper for boolean values that can be 1/0/yes/on/off/etc. +pub(crate) fn deserialize_flexible_bool<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok(match Value::deserialize(deserializer)? { + Value::Bool(b) => b, + Value::Number(n) => n.as_i64().is_some_and(|v| v != 0), + Value::String(s) => { + matches!(s.to_ascii_uppercase().as_str(), "1" | "on" | "yes" | "true") + } + _ => false, + }) +} diff --git a/src/page_mail_view.rs b/src/page_mail_view.rs index bdc5ffa..8f017b2 100644 --- a/src/page_mail_view.rs +++ b/src/page_mail_view.rs @@ -1,35 +1,39 @@ use std::rc::Rc; use anyhow::Error; +use serde::Deserialize; use serde_json::{json, Value}; use yew::virtual_dom::{VComp, VNode}; use proxmox_yew_comp::http_get; use pwt::dom::get_system_prefer_dark_mode; -use pwt::prelude::*; use pwt::state::{SharedState, Theme, ThemeObserver}; use pwt::touch::{ApplicationBar, FabMenu, FabMenuEntry, Scaffold, SnackBar, SnackBarContextExt}; use pwt::widget::form::Checkbox; use pwt::widget::{get_unique_element_id, FieldLabel, Row}; +use pwt::{prelude::*, AsyncPool}; -use crate::{mail_action, MailAction, QuarantineReload}; - -// whether the mail has external images the on-demand mode blocks, so the -// "Load images" toggle is only offered when it would actually fetch something -async fn mail_has_external_images(id: &str) -> bool { - match http_get::("/quarantine/content", Some(json!({ "id": id }))).await { - // a boolean can arrive as a JSON bool, number or one of the strings the - // PVE::JSONSchema boolean type accepts (1/on/yes/true vs 0/off/no/false) - Ok(data) => match &data["external_images"] { - Value::Bool(b) => *b, - Value::Number(n) => n.as_i64().unwrap_or(0) != 0, - Value::String(s) => { - matches!(s.to_ascii_lowercase().as_str(), "1" | "on" | "yes" | "true") - } - _ => false, - }, - Err(_) => false, +use crate::{deserialize_flexible_bool, mail_action, MailAction, QuarantineReload}; + +/// Result of /quarantine/content, with relevant fields only +#[derive(Clone, PartialEq, Deserialize)] +pub struct MailContentInfo { + #[serde(default, deserialize_with = "deserialize_flexible_bool")] + external_images: bool, + #[serde(default, deserialize_with = "deserialize_flexible_bool")] + seen: bool, +} + +// get the email info we need +async fn get_mail_info(id: &str) -> Option { + // ignore errors + match http_get::("/quarantine/content", Some(json!({ "id": id }))).await { + Ok(res) => Some(res), + Err(err) => { + log::error!("could not retrieve mail-content-info for {id}: {err}"); + None + } } } @@ -46,17 +50,18 @@ impl PageMailView { pub enum Msg { ActionResult(MailAction, Result), - DarkmodeFilter(bool), // on/off - DarkmodeChange(bool), // on/off - LoadImages(bool), // on/off - ExternalImages(bool), // mail has external images to load + DarkmodeFilter(bool), // on/off + DarkmodeChange(bool), // on/off + LoadImages(bool), // on/off + MailContentInfo(Option), // Loaded mail content info } pub struct PmgPageMailView { show_dark_mode_filter: bool, dark_mode_filter: bool, load_images: bool, - show_load_images: bool, + mail_content_res: Option, _theme_observer: ThemeObserver, + async_pool: AsyncPool, reload: Option>, } @@ -97,6 +102,14 @@ impl PmgPageMailView { } } + + fn reload_info(&self, ctx: &Context) { + let link = ctx.link().clone(); + let id = ctx.props().id.clone(); + self.async_pool.spawn(async move { + link.send_message(Msg::MailContentInfo(get_mail_info(&id).await)); + }); + } } impl Component for PmgPageMailView { @@ -121,20 +134,17 @@ impl Component for PmgPageMailView { .context::(Callback::noop()) .map(|(reload, _handle)| reload.0); - let link = ctx.link().clone(); - let id = ctx.props().id.clone(); - wasm_bindgen_futures::spawn_local(async move { - link.send_message(Msg::ExternalImages(mail_has_external_images(&id).await)); - }); - - Self { + let this = Self { dark_mode_filter, show_dark_mode_filter: dark_mode_filter, load_images: false, - show_load_images: false, + mail_content_res: None, _theme_observer, + async_pool: AsyncPool::new(), reload, - } + }; + this.reload_info(ctx); + this } fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { @@ -146,6 +156,9 @@ impl Component for PmgPageMailView { if let Some(reload) = &self.reload { **reload.write() += 1; } + if matches!(action, MailAction::MarkUnseen | MailAction::MarkSeen) { + self.reload_info(ctx); + } tr!("Action '{0}' successful", action) } Err(err) => err.to_string(), @@ -172,16 +185,16 @@ impl Component for PmgPageMailView { self.load_images = load_images; changed } - Msg::ExternalImages(show_load_images) => { - let changed = self.show_load_images != show_load_images; - self.show_load_images = show_load_images; + Msg::MailContentInfo(result) => { + let changed = self.mail_content_res != result; + self.mail_content_res = result; changed } } } fn view(&self, ctx: &Context) -> Html { - let fab = FabMenu::new() + let mut fab = FabMenu::new() .main_icon_class("fa fa-bars") .with_child(FabMenuEntry::new( tr!("Deliver"), @@ -202,21 +215,39 @@ impl Component for PmgPageMailView { tr!("Blocklist"), "fa fa-times", self.action_callback(ctx, MailAction::Blocklist), - )) - .with_child(FabMenuEntry::new( + )); + + // show both when we don't have any info + let (show_mark_seen, show_mark_unseen) = match &self.mail_content_res { + Some(info) => (!info.seen, info.seen), + None => (true, true), + }; + + if show_mark_seen { + fab.add_child(FabMenuEntry::new( tr!("Mark as Seen"), "fa fa-eye", self.action_callback(ctx, MailAction::MarkSeen), - )) - .with_child(FabMenuEntry::new( + )); + } + + if show_mark_unseen { + fab.add_child(FabMenuEntry::new( tr!("Mark as Unseen"), "fa fa-eye-slash", self.action_callback(ctx, MailAction::MarkUnseen), )); + } let mut app_bar = ApplicationBar::new().title(tr!("Preview")); - if self.show_load_images { + let show_load_images = self + .mail_content_res + .as_ref() + .map(|info| info.external_images) + .unwrap_or(false); + + if show_load_images { let id = get_unique_element_id(); app_bar.add_action( Row::new() diff --git a/src/spam_list.rs b/src/spam_list.rs index a7f1bd2..3f67179 100644 --- a/src/spam_list.rs +++ b/src/spam_list.rs @@ -21,7 +21,7 @@ use pwt::{ use proxmox_yew_comp::http_get; use pwt::widget::Column; -use crate::{mail_action, MailAction, QuarantineReload}; +use crate::{deserialize_flexible_bool, mail_action, MailAction, QuarantineReload}; #[derive(Copy, Clone, Serialize, Default, PartialEq)] pub struct SpamListParam { @@ -53,18 +53,6 @@ pub struct MailInfo { pub time: i64, } -fn deserialize_flexible_bool<'de, D>(deserializer: D) -> Result -where - D: serde::Deserializer<'de>, -{ - Ok(match Value::deserialize(deserializer)? { - Value::Bool(b) => b, - Value::Number(n) => n.as_i64().is_some_and(|v| v != 0), - Value::String(s) => s == "1" || s.eq_ignore_ascii_case("true"), - _ => false, - }) -} - #[derive(Clone)] pub enum ListEntry { Date(String), -- 2.47.3