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 5303F1FF142 for ; Fri, 19 Jun 2026 11:46:02 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 2964BA179; Fri, 19 Jun 2026 11:46:02 +0200 (CEST) Date: Fri, 19 Jun 2026 11:45:26 +0200 From: Stoiko Ivanov To: Dominik Csapak Subject: Re: [PATCH pmg-yew-quarantine-gui v2] mail view: show mark seen/unseen depending on mail state Message-ID: <20260619114526.503074a2@rosa.proxmox.com> In-Reply-To: <20260619093152.1440414-1-d.csapak@proxmox.com> References: <20260619093152.1440414-1-d.csapak@proxmox.com> X-Mailer: Claws Mail 4.3.1 (GTK 3.24.49; x86_64-pc-linux-gnu) MIME-Version: 1.0 Content-Type: text/plain; charset=US-ASCII Content-Transfer-Encoding: 7bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1781862321748 X-SPAM-LEVEL: Spam detection results: 0 AWL -1.067 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 URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [main.rs] Message-ID-Hash: 7MSCJFF7JJOH7PONYCO4Y57RFLTENLD2 X-Message-ID-Hash: 7MSCJFF7JJOH7PONYCO4Y57RFLTENLD2 X-MailFrom: s.ivanov@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 CC: pmg-devel@lists.proxmox.com 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: Thanks for the quick iteration! verified the change and gave it another spin - still works as advertised: Reviewed-by: Stoiko Ivanov Tested-by: Stoiko Ivanov On Fri, 19 Jun 2026 11:31:22 +0200 Dominik Csapak wrote: > 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 > --- > changes from v1: > * use to_ascii_lowercase instead of wrong uppercase. > > 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..5414e33 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_lowercase().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 95a0611..6ea3e1d 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),