From: Lukas Wagner <l.wagner@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [pdm-devel] [PATCH datacenter-manager 1/3] pdm-api-types: views: preparations for future glob/regex support
Date: Mon, 17 Nov 2025 15:11:20 +0100 [thread overview]
Message-ID: <20251117141122.328559-2-l.wagner@proxmox.com> (raw)
In-Reply-To: <20251117141122.328559-1-l.wagner@proxmox.com>
Change the config format slightly in a way similar to how the
'match-field' statements work for notification matchers.
The new format is, in pseudo-regex:
(include|exclude) (exact:)?(resource-pool|...|tag)=.*
The 'exact:' part is optional. Later we can add "regex" or "glob" types
to add support for globbing or regex without any changes to the config
format.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
lib/pdm-api-types/src/views.rs | 185 +++++++++++++++++++++++----------
server/src/views/mod.rs | 24 +++--
server/src/views/tests.rs | 110 ++++++++++++--------
3 files changed, 215 insertions(+), 104 deletions(-)
diff --git a/lib/pdm-api-types/src/views.rs b/lib/pdm-api-types/src/views.rs
index ef39cc62..950a3a04 100644
--- a/lib/pdm-api-types/src/views.rs
+++ b/lib/pdm-api-types/src/views.rs
@@ -1,6 +1,6 @@
-use std::{fmt::Display, str::FromStr, sync::OnceLock};
+use std::{fmt::Debug, fmt::Display, str::FromStr, sync::OnceLock};
-use anyhow::bail;
+use anyhow::{bail, Error};
use const_format::concatcp;
use serde::{Deserialize, Serialize};
@@ -23,11 +23,11 @@ const_regex! {
pub const FILTER_RULE_SCHEMA: Schema = StringSchema::new("Filter rule for resources.")
.format(&ApiStringFormat::VerifyFn(verify_filter_rule))
.type_text(
- "resource-type:<storage|qemu|lxc|sdn-zone|datastore|node>\
- |resource-pool:<pool-name>\
- |tag:<tag-name>\
- |remote:<remote-name>\
- |resource-id:<resource-id>",
+ "[exact:]resource-type=<storage|qemu|lxc|sdn-zone|datastore|node>\
+ |[exact:]resource-pool=<pool-name>\
+ |[exact:]tag=<tag-name>\
+ |[exact:]remote=<remote-name>\
+ |[exact:]resource=id:<resource-id>",
)
.schema();
@@ -102,65 +102,112 @@ impl ApiSectionDataEntry for ViewConfigEntry {
}
}
+#[derive(Clone, Debug, PartialEq)]
+/// Matcher for string-based values.
+pub enum StringMatcher {
+ Exact(String),
+}
+
+impl StringMatcher {
+ /// Check if a given string matches.
+ pub fn matches(&self, value: &str) -> bool {
+ match self {
+ StringMatcher::Exact(matched_value) => value == matched_value,
+ }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+/// Matcher for enum-based values.
+pub struct EnumMatcher<T: PartialEq + Clone + Debug>(pub T);
+
+impl<T: PartialEq + Debug + Clone> EnumMatcher<T> {
+ /// Check if a given value matches.
+ pub fn matches(&self, value: &T) -> bool {
+ self.0 == *value
+ }
+}
+
#[derive(Clone, Debug, PartialEq)]
/// Filter rule for includes/excludes.
pub enum FilterRule {
/// Match a resource type.
- ResourceType(ResourceType),
+ ResourceType(EnumMatcher<ResourceType>),
/// Match a resource pools (for PVE guests).
- ResourcePool(String),
+ ResourcePool(StringMatcher),
/// Match a (global) resource ID, e.g. 'remote/<remote>/guest/<vmid>'.
- ResourceId(String),
+ ResourceId(StringMatcher),
/// Match a tag (for PVE guests).
- Tag(String),
+ Tag(StringMatcher),
/// Match a remote.
- Remote(String),
+ Remote(StringMatcher),
}
impl FromStr for FilterRule {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
- Ok(match s.split_once(':') {
- Some(("resource-type", value)) => FilterRule::ResourceType(value.parse()?),
- Some(("resource-pool", value)) => {
- if !PROXMOX_SAFE_ID_REGEX.is_match(value) {
- bail!("invalid resource-pool value: {value}");
- }
- FilterRule::ResourcePool(value.to_string())
- }
- Some(("resource-id", value)) => {
- if !GLOBAL_RESOURCE_ID_REGEX.is_match(value) {
- bail!("invalid resource-id value: {value}");
- }
-
- FilterRule::ResourceId(value.to_string())
- }
- Some(("tag", value)) => {
- if !PROXMOX_SAFE_ID_REGEX.is_match(value) {
- bail!("invalid tag value: {value}");
- }
- FilterRule::Tag(value.to_string())
- }
- Some(("remote", value)) => {
- let _ = REMOTE_ID_SCHEMA.parse_simple_value(value)?;
- FilterRule::Remote(value.to_string())
- }
- Some((ty, _)) => bail!("invalid type: {ty}"),
- None => bail!("invalid filter rule: {s}"),
- })
+ if let Some(s) = s.strip_prefix("exact:") {
+ parse_filter_rule(s)
+ } else {
+ parse_filter_rule(s)
+ }
}
}
+fn parse_filter_rule(s: &str) -> Result<FilterRule, Error> {
+ Ok(match s.split_once('=') {
+ Some(("resource-type", value)) => FilterRule::ResourceType(EnumMatcher(value.parse()?)),
+ Some(("resource-pool", value)) => {
+ if !PROXMOX_SAFE_ID_REGEX.is_match(value) {
+ bail!("invalid resource-pool value: {value}");
+ }
+
+ let val = StringMatcher::Exact(value.into());
+ FilterRule::ResourcePool(val)
+ }
+ Some(("resource-id", value)) => {
+ if !GLOBAL_RESOURCE_ID_REGEX.is_match(value) {
+ bail!("invalid resource-id value: {value}");
+ }
+
+ let val = StringMatcher::Exact(value.into());
+ FilterRule::ResourceId(val)
+ }
+ Some(("tag", value)) => {
+ if !PROXMOX_SAFE_ID_REGEX.is_match(value) {
+ bail!("invalid tag value: {value}");
+ }
+ let val = StringMatcher::Exact(value.into());
+ FilterRule::Tag(val)
+ }
+ Some(("remote", value)) => {
+ if !PROXMOX_SAFE_ID_REGEX.is_match(value) {
+ let _ = REMOTE_ID_SCHEMA.parse_simple_value(value)?;
+ }
+ let val = StringMatcher::Exact(value.into());
+ FilterRule::Remote(val)
+ }
+ Some((ty, _)) => bail!("invalid type: {ty}"),
+ None => bail!("invalid filter rule: {s}"),
+ })
+}
+
// used for serializing below, caution!
impl Display for FilterRule {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
- FilterRule::ResourceType(resource_type) => write!(f, "resource-type:{resource_type}"),
- FilterRule::ResourcePool(pool) => write!(f, "resource-pool:{pool}"),
- FilterRule::ResourceId(id) => write!(f, "resource-id:{id}"),
- FilterRule::Tag(tag) => write!(f, "tag:{tag}"),
- FilterRule::Remote(remote) => write!(f, "remote:{remote}"),
+ FilterRule::ResourceType(EnumMatcher(resource_type)) => {
+ write!(f, "exact:resource-type={resource_type}")
+ }
+ FilterRule::ResourceId(StringMatcher::Exact(value)) => {
+ write!(f, "exact:resource-id={value}")
+ }
+ FilterRule::Tag(StringMatcher::Exact(value)) => write!(f, "exact:tag={value}"),
+ FilterRule::Remote(StringMatcher::Exact(value)) => write!(f, "exact:remote={value}"),
+ FilterRule::ResourcePool(StringMatcher::Exact(value)) => {
+ write!(f, "exact:resource-pool={value}")
+ }
}
}
}
@@ -176,7 +223,9 @@ fn verify_filter_rule(input: &str) -> Result<(), anyhow::Error> {
mod test {
use anyhow::Error;
- use crate::views::FilterRule;
+ use proxmox_section_config::typed::ApiSectionDataEntry;
+
+ use super::*;
fn parse_and_check_display(input: &str) -> Result<bool, Error> {
let rule: FilterRule = input.parse()?;
@@ -190,21 +239,49 @@ mod test {
assert!(parse_and_check_display("abc:").is_err());
assert!(parse_and_check_display("resource-type:").is_err());
- assert!(parse_and_check_display("resource-type:lxc").unwrap());
- assert!(parse_and_check_display("resource-type:qemu").unwrap());
- assert!(parse_and_check_display("resource-type:abc").is_err());
+ assert!(parse_and_check_display("exact:resource-type=lxc").unwrap());
+ assert!(parse_and_check_display("exact:resource-type=qemu").unwrap());
+ assert!(parse_and_check_display("exact:resource-type=abc").is_err());
assert!(parse_and_check_display("resource-pool:").is_err());
- assert!(parse_and_check_display("resource-pool:somepool").unwrap());
+ assert!(parse_and_check_display("exact:resource-pool=somepool").unwrap());
assert!(parse_and_check_display("resource-id:").is_err());
- assert!(parse_and_check_display("resource-id:remote/someremote/guest/100").unwrap());
- assert!(parse_and_check_display("resource-id:remote").is_err());
+ assert!(parse_and_check_display("exact:resource-id=remote/someremote/guest/100").unwrap());
+ assert!(parse_and_check_display("exact:resource-id=remote").is_err());
assert!(parse_and_check_display("tag:").is_err());
- assert!(parse_and_check_display("tag:sometag").unwrap());
+ assert!(parse_and_check_display("exact:tag=sometag").unwrap());
- assert!(parse_and_check_display("remote:someremote").unwrap());
+ assert!(parse_and_check_display("exact:remote=someremote").unwrap());
assert!(parse_and_check_display("remote:a").is_err());
}
+
+ #[test]
+ fn config_smoke_test() {
+ let config = "
+view: some-view
+ include exact:remote=someremote
+ include remote=someremote
+ include resource-type=qemu
+ include exact:resource-type=qemu
+ include resource-id=remote/someremote/guest/100
+ include exact:resource-id=remote/someremote/guest/100
+ include tag=sometag
+ include exact:tag=sometag
+ include resource-pool=somepool
+ include exact:resource-pool=somepool
+ exclude remote=someremote
+ exclude exact:remote=someremote
+ exclude resource-type=qemu
+ exclude exact:resource-type=qemu
+ exclude resource-id=remote/someremote/guest/100
+ exclude exact:resource-id=remote/someremote/guest/100
+ exclude tag=sometag
+ exclude exact:tag=sometag
+ exclude resource-pool=somepool
+ exclude exact:resource-pool=somepool
+";
+ ViewConfigEntry::parse_section_config("views.cfg", config).unwrap();
+ }
}
diff --git a/server/src/views/mod.rs b/server/src/views/mod.rs
index 80f8425c..8b4c70a4 100644
--- a/server/src/views/mod.rs
+++ b/server/src/views/mod.rs
@@ -72,7 +72,7 @@ impl View {
for include in &self.config.include {
if let FilterRule::Remote(r) = include {
has_any_include_remote = true;
- if r == remote {
+ if r.matches(remote) {
matches_any_include_remote = true;
break;
}
@@ -145,7 +145,7 @@ impl View {
fn matches_remote_rule(remote: &str, rule: &FilterRule) -> bool {
if let FilterRule::Remote(r) = rule {
- r == remote
+ r.matches(remote)
} else {
false
}
@@ -154,17 +154,23 @@ impl View {
fn check_rules(rules: &[FilterRule], remote: &str, resource: &ResourceData) -> bool {
rules.iter().any(|rule| match rule {
- FilterRule::ResourceType(resource_type) => resource.resource_type == *resource_type,
- FilterRule::ResourcePool(pool) => resource.resource_pool == Some(pool),
- FilterRule::ResourceId(resource_id) => resource.resource_id == resource_id,
- FilterRule::Tag(tag) => {
- if let Some(resource_tags) = resource.tags {
- resource_tags.contains(tag)
+ FilterRule::ResourceType(resource_type) => resource_type.matches(&resource.resource_type),
+ FilterRule::ResourcePool(pool) => {
+ if let Some(resource_pool) = resource.resource_pool {
+ pool.matches(resource_pool)
} else {
false
}
}
- FilterRule::Remote(included_remote) => included_remote == remote,
+ FilterRule::ResourceId(resource_id) => resource_id.matches(resource.resource_id),
+ FilterRule::Tag(tag) => {
+ if let Some(resource_tags) = resource.tags {
+ resource_tags.iter().any(|t| tag.matches(t))
+ } else {
+ false
+ }
+ }
+ FilterRule::Remote(included_remote) => included_remote.matches(remote),
})
}
diff --git a/server/src/views/tests.rs b/server/src/views/tests.rs
index 030b7994..14d94ac9 100644
--- a/server/src/views/tests.rs
+++ b/server/src/views/tests.rs
@@ -1,6 +1,6 @@
use pdm_api_types::{
resource::{PveLxcResource, PveQemuResource, PveStorageResource, Resource, ResourceType},
- views::{FilterRule, ViewConfig},
+ views::{EnumMatcher, FilterRule, StringMatcher, ViewConfig},
};
use super::View;
@@ -88,8 +88,8 @@ fn include_remotes() {
let config = ViewConfig {
id: "only-includes".into(),
include: vec![
- FilterRule::Remote("remote-a".into()),
- FilterRule::Remote("remote-b".into()),
+ FilterRule::Remote(StringMatcher::Exact("remote-a".into())),
+ FilterRule::Remote(StringMatcher::Exact("remote-b".into())),
],
..Default::default()
};
@@ -132,8 +132,8 @@ fn exclude_remotes() {
let config = ViewConfig {
id: "only-excludes".into(),
exclude: vec![
- FilterRule::Remote("remote-a".into()),
- FilterRule::Remote("remote-b".into()),
+ FilterRule::Remote(StringMatcher::Exact("remote-a".into())),
+ FilterRule::Remote(StringMatcher::Exact("remote-b".into())),
],
..Default::default()
};
@@ -177,12 +177,12 @@ fn include_exclude_remotes() {
let config = ViewConfig {
id: "both".into(),
include: vec![
- FilterRule::Remote("remote-a".into()),
- FilterRule::Remote("remote-b".into()),
+ FilterRule::Remote(StringMatcher::Exact("remote-a".into())),
+ FilterRule::Remote(StringMatcher::Exact("remote-b".into())),
],
exclude: vec![
- FilterRule::Remote("remote-b".into()),
- FilterRule::Remote("remote-c".into()),
+ FilterRule::Remote(StringMatcher::Exact("remote-b".into())),
+ FilterRule::Remote(StringMatcher::Exact("remote-c".into())),
],
};
run_test(
@@ -270,8 +270,8 @@ fn include_type() {
ViewConfig {
id: "include-resource-type".into(),
include: vec![
- FilterRule::ResourceType(ResourceType::PveStorage),
- FilterRule::ResourceType(ResourceType::PveQemu),
+ FilterRule::ResourceType(EnumMatcher(ResourceType::PveStorage)),
+ FilterRule::ResourceType(EnumMatcher(ResourceType::PveQemu)),
],
..Default::default()
},
@@ -298,8 +298,8 @@ fn exclude_type() {
ViewConfig {
id: "exclude-resource-type".into(),
exclude: vec![
- FilterRule::ResourceType(ResourceType::PveStorage),
- FilterRule::ResourceType(ResourceType::PveQemu),
+ FilterRule::ResourceType(EnumMatcher(ResourceType::PveStorage)),
+ FilterRule::ResourceType(EnumMatcher(ResourceType::PveQemu)),
],
..Default::default()
},
@@ -325,8 +325,10 @@ fn include_exclude_type() {
run_test(
ViewConfig {
id: "exclude-resource-type".into(),
- include: vec![FilterRule::ResourceType(ResourceType::PveQemu)],
- exclude: vec![FilterRule::ResourceType(ResourceType::PveStorage)],
+ include: vec![FilterRule::ResourceType(EnumMatcher(ResourceType::PveQemu))],
+ exclude: vec![FilterRule::ResourceType(EnumMatcher(
+ ResourceType::PveStorage,
+ ))],
},
&[
(
@@ -351,10 +353,10 @@ fn include_exclude_tags() {
ViewConfig {
id: "include-tags".into(),
include: vec![
- FilterRule::Tag("tag1".to_string()),
- FilterRule::Tag("tag2".to_string()),
+ FilterRule::Tag(StringMatcher::Exact("tag1".to_string())),
+ FilterRule::Tag(StringMatcher::Exact("tag2".to_string())),
],
- exclude: vec![FilterRule::Tag("tag3".to_string())],
+ exclude: vec![FilterRule::Tag(StringMatcher::Exact("tag3".to_string()))],
},
&[
(
@@ -396,10 +398,12 @@ fn include_exclude_resource_pool() {
ViewConfig {
id: "pools".into(),
include: vec![
- FilterRule::ResourcePool("pool1".to_string()),
- FilterRule::ResourcePool("pool2".to_string()),
+ FilterRule::ResourcePool(StringMatcher::Exact("pool1".to_string())),
+ FilterRule::ResourcePool(StringMatcher::Exact("pool2".to_string())),
],
- exclude: vec![FilterRule::ResourcePool("pool2".to_string())],
+ exclude: vec![FilterRule::ResourcePool(StringMatcher::Exact(
+ "pool2".to_string(),
+ ))],
},
&[
(
@@ -441,13 +445,19 @@ fn include_exclude_resource_id() {
ViewConfig {
id: "resource-id".into(),
include: vec![
- FilterRule::ResourceId(format!("remote/{REMOTE}/guest/100")),
- FilterRule::ResourceId(format!("remote/{REMOTE}/storage/{NODE}/{STORAGE}")),
+ FilterRule::ResourceId(StringMatcher::Exact(format!("remote/{REMOTE}/guest/100"))),
+ FilterRule::ResourceId(StringMatcher::Exact(format!(
+ "remote/{REMOTE}/storage/{NODE}/{STORAGE}"
+ ))),
],
exclude: vec![
- FilterRule::ResourceId(format!("remote/{REMOTE}/guest/101")),
- FilterRule::ResourceId("remote/otherremote/guest/101".to_string()),
- FilterRule::ResourceId(format!("remote/{REMOTE}/storage/{NODE}/otherstorage")),
+ FilterRule::ResourceId(StringMatcher::Exact(format!("remote/{REMOTE}/guest/101"))),
+ FilterRule::ResourceId(StringMatcher::Exact(
+ "remote/otherremote/guest/101".to_string(),
+ )),
+ FilterRule::ResourceId(StringMatcher::Exact(format!(
+ "remote/{REMOTE}/storage/{NODE}/otherstorage"
+ ))),
],
},
&[
@@ -491,10 +501,14 @@ fn node_included() {
id: "both".into(),
include: vec![
- FilterRule::Remote("remote-a".to_string()),
- FilterRule::ResourceId("remote/someremote/node/test".to_string()),
+ FilterRule::Remote(StringMatcher::Exact("remote-a".to_string())),
+ FilterRule::ResourceId(StringMatcher::Exact(
+ "remote/someremote/node/test".to_string(),
+ )),
],
- exclude: vec![FilterRule::Remote("remote-b".to_string())],
+ exclude: vec![FilterRule::Remote(StringMatcher::Exact(
+ "remote-b".to_string(),
+ ))],
});
assert!(view.is_node_included("remote-a", "somenode"));
@@ -511,7 +525,9 @@ fn can_skip_remote_if_excluded() {
let view = View::new(ViewConfig {
id: "abc".into(),
include: vec![],
- exclude: vec![FilterRule::Remote("remote-b".to_string())],
+ exclude: vec![FilterRule::Remote(StringMatcher::Exact(
+ "remote-b".to_string(),
+ ))],
});
assert!(!view.can_skip_remote("remote-a"));
@@ -522,7 +538,9 @@ fn can_skip_remote_if_excluded() {
fn can_skip_remote_if_included() {
let view = View::new(ViewConfig {
id: "abc".into(),
- include: vec![FilterRule::Remote("remote-b".to_string())],
+ include: vec![FilterRule::Remote(StringMatcher::Exact(
+ "remote-b".to_string(),
+ ))],
exclude: vec![],
});
@@ -535,8 +553,10 @@ fn can_skip_remote_cannot_skip_if_any_other_include() {
let view = View::new(ViewConfig {
id: "abc".into(),
include: vec![
- FilterRule::Remote("remote-b".to_string()),
- FilterRule::ResourceId("resource/remote-a/guest/100".to_string()),
+ FilterRule::Remote(StringMatcher::Exact("remote-b".to_string())),
+ FilterRule::ResourceId(StringMatcher::Exact(
+ "resource/remote-a/guest/100".to_string(),
+ )),
],
exclude: vec![],
});
@@ -549,10 +569,12 @@ fn can_skip_remote_cannot_skip_if_any_other_include() {
fn can_skip_remote_explicit_remote_exclude() {
let view = View::new(ViewConfig {
id: "abc".into(),
- include: vec![FilterRule::ResourceId(
+ include: vec![FilterRule::ResourceId(StringMatcher::Exact(
"resource/remote-a/guest/100".to_string(),
- )],
- exclude: vec![FilterRule::Remote("remote-a".to_string())],
+ ))],
+ exclude: vec![FilterRule::Remote(StringMatcher::Exact(
+ "remote-a".to_string(),
+ ))],
});
assert!(view.can_skip_remote("remote-a"));
@@ -574,9 +596,9 @@ fn can_skip_remote_with_empty_config() {
fn can_skip_remote_with_no_remote_includes() {
let view = View::new(ViewConfig {
id: "abc".into(),
- include: vec![FilterRule::ResourceId(
+ include: vec![FilterRule::ResourceId(StringMatcher::Exact(
"resource/remote-a/guest/100".to_string(),
- )],
+ ))],
exclude: vec![],
});
@@ -588,7 +610,9 @@ fn can_skip_remote_with_no_remote_includes() {
fn explicitly_included_remote() {
let view = View::new(ViewConfig {
id: "abc".into(),
- include: vec![FilterRule::Remote("remote-b".to_string())],
+ include: vec![FilterRule::Remote(StringMatcher::Exact(
+ "remote-b".to_string(),
+ ))],
exclude: vec![],
});
@@ -599,8 +623,12 @@ fn explicitly_included_remote() {
fn included_and_excluded_same_remote() {
let view = View::new(ViewConfig {
id: "abc".into(),
- include: vec![FilterRule::Remote("remote-b".to_string())],
- exclude: vec![FilterRule::Remote("remote-b".to_string())],
+ include: vec![FilterRule::Remote(StringMatcher::Exact(
+ "remote-b".to_string(),
+ ))],
+ exclude: vec![FilterRule::Remote(StringMatcher::Exact(
+ "remote-b".to_string(),
+ ))],
});
assert!(!view.is_remote_explicitly_included("remote-b"));
--
2.47.3
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
next prev parent reply other threads:[~2025-11-17 14:11 UTC|newest]
Thread overview: 4+ messages / expand[flat|nested] mbox.gz Atom feed top
2025-11-17 14:11 [pdm-devel] [PATCH datacenter-manager 0/3] views: preparations for regex/glob, include-all param Lukas Wagner
2025-11-17 14:11 ` Lukas Wagner [this message]
2025-11-17 14:11 ` [pdm-devel] [PATCH datacenter-manager 2/3] views: add 'include-all' param; change semantics when there are no includes Lukas Wagner
2025-11-17 14:11 ` [pdm-devel] [PATCH datacenter-manager 3/3] views: tests: use full section-config format for test cases Lukas Wagner
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20251117141122.328559-2-l.wagner@proxmox.com \
--to=l.wagner@proxmox.com \
--cc=pdm-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox