melib/imap/managesieve: implement some rfc5804 commands

Try with managesieve REPL in src/managesieve.rs:

cargo run --bin managesieve-client ~/.config/meli/config.toml
"accountname"

rfc5804 <https://www.rfc-editor.org/rfc/rfc5804.html>
pull/161/head
Manos Pitsidianakis 2 years ago
parent 3697b7d960
commit 803d3414fd

@ -21,9 +21,9 @@ path = "src/main.rs"
name = "meli"
path = "src/lib.rs"
#[[bin]]
#name = "managesieve-meli"
#path = "src/managesieve.rs"
[[bin]]
name = "managesieve-client"
path = "src/managesieve.rs"
#[[bin]]
#name = "async"

@ -21,16 +21,22 @@
use super::{ImapConnection, ImapProtocol, ImapServerConf, UIDStore};
use crate::conf::AccountSettings;
use crate::email::parser::IResult;
use crate::error::{MeliError, Result};
use crate::get_conf_val;
use crate::imap::RequiredResponses;
use nom::{
branch::alt, bytes::complete::tag, combinator::map, error::Error as NomError, error::ErrorKind,
multi::separated_list1, sequence::separated_pair, IResult,
branch::alt, bytes::complete::tag, combinator::map, multi::separated_list1,
sequence::separated_pair,
};
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
pub struct ManageSieveConnection {
pub inner: ImapConnection,
}
pub fn managesieve_capabilities(input: &[u8]) -> Result<Vec<(&[u8], &[u8])>> {
let (_, ret) = separated_list1(
tag(b"\r\n"),
@ -42,26 +48,225 @@ pub fn managesieve_capabilities(input: &[u8]) -> Result<Vec<(&[u8], &[u8])>> {
Ok(ret)
}
#[test]
fn test_managesieve_capabilities() {
assert_eq!(managesieve_capabilities(b"\"IMPLEMENTATION\" \"Dovecot Pigeonhole\"\r\n\"SIEVE\" \"fileinto reject envelope encoded-character vacation subaddress comparator-i;ascii-numeric relational regex imap4flags copy include variables body enotify environment mailbox date index ihave duplicate mime foreverypart extracttext\"\r\n\"NOTIFY\" \"mailto\"\r\n\"SASL\" \"PLAIN\"\r\n\"STARTTLS\"\r\n\"VERSION\" \"1.0\"\r\n").unwrap(), vec![
(&b"IMPLEMENTATION"[..],&b"Dovecot Pigeonhole"[..]),
(&b"SIEVE"[..],&b"fileinto reject envelope encoded-character vacation subaddress comparator-i;ascii-numeric relational regex imap4flags copy include variables body enotify environment mailbox date index ihave duplicate mime foreverypart extracttext"[..]),
(&b"NOTIFY"[..],&b"mailto"[..]),
(&b"SASL"[..],&b"PLAIN"[..]),
(&b"STARTTLS"[..], &b""[..]),
(&b"VERSION"[..],&b"1.0"[..])]
);
#[derive(PartialEq, Eq, Debug, Copy, Clone)]
pub enum ManageSieveResponse<'a> {
Ok {
code: Option<&'a [u8]>,
message: Option<&'a [u8]>,
},
NoBye {
code: Option<&'a [u8]>,
message: Option<&'a [u8]>,
},
}
mod parser {
use super::*;
use nom::bytes::complete::tag;
pub use nom::bytes::complete::{is_not, tag_no_case};
use nom::character::complete::crlf;
use nom::combinator::{iterator, map, opt};
pub use nom::sequence::{delimited, pair, preceded, terminated};
pub fn sieve_name(input: &[u8]) -> IResult<&[u8], &[u8]> {
crate::backends::imap::protocol_parser::string_token(input)
}
// *(sieve-name [SP "ACTIVE"] CRLF)
// response-oknobye
pub fn listscripts(input: &[u8]) -> IResult<&[u8], Vec<(&[u8], bool)>> {
let mut it = iterator(
input,
alt((
terminated(
map(terminated(sieve_name, tag_no_case(b" ACTIVE")), |r| {
(r, true)
}),
crlf,
),
terminated(map(sieve_name, |r| (r, false)), crlf),
)),
);
let parsed = (&mut it).collect::<Vec<(&[u8], bool)>>();
let res: IResult<_, _> = it.finish();
let (rest, _) = res?;
Ok((rest, parsed))
}
// response-getscript = (sieve-script CRLF response-ok) /
// response-nobye
pub fn getscript(input: &[u8]) -> IResult<&[u8], &[u8]> {
sieve_name(input)
}
pub fn response_oknobye(input: &[u8]) -> IResult<&[u8], ManageSieveResponse> {
alt((
map(
terminated(
pair(
preceded(
tag_no_case(b"ok"),
opt(preceded(
tag(b" "),
delimited(tag(b"("), is_not(")"), tag(b")")),
)),
),
opt(preceded(tag(b" "), sieve_name)),
),
crlf,
),
|(code, message)| ManageSieveResponse::Ok { code, message },
),
map(
terminated(
pair(
preceded(
alt((tag_no_case(b"no"), tag_no_case(b"bye"))),
opt(preceded(
tag(b" "),
delimited(tag(b"("), is_not(")"), tag(b")")),
)),
),
opt(preceded(tag(b" "), sieve_name)),
),
crlf,
),
|(code, message)| ManageSieveResponse::NoBye { code, message },
),
))(input)
}
#[test]
fn test_managesieve_listscripts() {
let input_1 = b"\"summer_script\"\r\n\"vacation_script\"\r\n{13}\r\nclever\"script\r\n\"main_script\" ACTIVE\r\nOK";
assert_eq!(
terminated(listscripts, tag_no_case(b"OK"))(input_1),
Ok((
&b""[..],
vec![
(&b"summer_script"[..], false),
(&b"vacation_script"[..], false),
(&b"clever\"script"[..], false),
(&b"main_script"[..], true)
]
))
);
let input_2 = b"\"summer_script\"\r\n\"main_script\" active\r\nok";
assert_eq!(
terminated(listscripts, tag_no_case(b"OK"))(input_2),
Ok((
&b""[..],
vec![(&b"summer_script"[..], false), (&b"main_script"[..], true)]
))
);
let input_3 = b"ok";
assert_eq!(
terminated(listscripts, tag_no_case(b"OK"))(input_3),
Ok((&b""[..], vec![]))
);
}
#[test]
fn test_managesieve_general() {
assert_eq!(managesieve_capabilities(b"\"IMPLEMENTATION\" \"Dovecot Pigeonhole\"\r\n\"SIEVE\" \"fileinto reject envelope encoded-character vacation subaddress comparator-i;ascii-numeric relational regex imap4flags copy include variables body enotify environment mailbox date index ihave duplicate mime foreverypart extracttext\"\r\n\"NOTIFY\" \"mailto\"\r\n\"SASL\" \"PLAIN\"\r\n\"STARTTLS\"\r\n\"VERSION\" \"1.0\"\r\n").unwrap(), vec![
(&b"IMPLEMENTATION"[..],&b"Dovecot Pigeonhole"[..]),
(&b"SIEVE"[..],&b"fileinto reject envelope encoded-character vacation subaddress comparator-i;ascii-numeric relational regex imap4flags copy include variables body enotify environment mailbox date index ihave duplicate mime foreverypart extracttext"[..]),
(&b"NOTIFY"[..],&b"mailto"[..]),
(&b"SASL"[..],&b"PLAIN"[..]),
(&b"STARTTLS"[..], &b""[..]),
(&b"VERSION"[..],&b"1.0"[..])]
);
let response_ok = b"OK (WARNINGS) \"line 8: server redirect action limit is 2, this redirect might be ignored\"\r\n";
assert_eq!(
response_oknobye(response_ok),
Ok((
&b""[..],
ManageSieveResponse::Ok {
code: Some(&b"WARNINGS"[..]),
message: Some(&b"line 8: server redirect action limit is 2, this redirect might be ignored"[..]),
}
))
);
let response_ok = b"OK (WARNINGS)\r\n";
assert_eq!(
response_oknobye(response_ok),
Ok((
&b""[..],
ManageSieveResponse::Ok {
code: Some(&b"WARNINGS"[..]),
message: None,
}
))
);
let response_ok =
b"OK \"line 8: server redirect action limit is 2, this redirect might be ignored\"\r\n";
assert_eq!(
response_oknobye(response_ok),
Ok((
&b""[..],
ManageSieveResponse::Ok {
code: None,
message: Some(&b"line 8: server redirect action limit is 2, this redirect might be ignored"[..]),
}
))
);
let response_ok = b"Ok\r\n";
assert_eq!(
response_oknobye(response_ok),
Ok((
&b""[..],
ManageSieveResponse::Ok {
code: None,
message: None,
}
))
);
let response_nobye = b"No (NONEXISTENT) \"There is no script by that name\"\r\n";
assert_eq!(
response_oknobye(response_nobye),
Ok((
&b""[..],
ManageSieveResponse::NoBye {
code: Some(&b"NONEXISTENT"[..]),
message: Some(&b"There is no script by that name"[..]),
}
))
);
let response_nobye = b"No (NONEXISTENT) {31}\r\nThere is no script by that name\r\n";
assert_eq!(
response_oknobye(response_nobye),
Ok((
&b""[..],
ManageSieveResponse::NoBye {
code: Some(&b"NONEXISTENT"[..]),
message: Some(&b"There is no script by that name"[..]),
}
))
);
let response_nobye = b"No\r\n";
assert_eq!(
response_oknobye(response_nobye),
Ok((
&b""[..],
ManageSieveResponse::NoBye {
code: None,
message: None,
}
))
);
}
}
// Return a byte sequence surrounded by "s and decoded if necessary
pub fn quoted_raw(input: &[u8]) -> IResult<&[u8], &[u8]> {
if input.is_empty() || input[0] != b'"' {
return Err(nom::Err::Error(NomError {
input,
code: ErrorKind::Tag,
}));
return Err(nom::Err::Error((input, "empty").into()));
}
let mut i = 1;
@ -72,91 +277,199 @@ pub fn quoted_raw(input: &[u8]) -> IResult<&[u8], &[u8]> {
i += 1;
}
Err(nom::Err::Error(NomError {
input,
code: ErrorKind::Tag,
}))
}
pub trait ManageSieve {
fn havespace(&mut self) -> Result<()>;
fn putscript(&mut self) -> Result<()>;
fn listscripts(&mut self) -> Result<()>;
fn setactive(&mut self) -> Result<()>;
fn getscript(&mut self) -> Result<()>;
fn deletescript(&mut self) -> Result<()>;
fn renamescript(&mut self) -> Result<()>;
Err(nom::Err::Error((input, "no quotes").into()))
}
pub fn new_managesieve_connection(
account_hash: crate::backends::AccountHash,
account_name: String,
s: &AccountSettings,
event_consumer: crate::backends::BackendEventConsumer,
) -> Result<ImapConnection> {
let server_hostname = get_conf_val!(s["server_hostname"])?;
let server_username = get_conf_val!(s["server_username"])?;
let server_password = get_conf_val!(s["server_password"])?;
let server_port = get_conf_val!(s["server_port"], 4190)?;
let danger_accept_invalid_certs: bool = get_conf_val!(s["danger_accept_invalid_certs"], false)?;
let timeout = get_conf_val!(s["timeout"], 16_u64)?;
let timeout = if timeout == 0 {
None
} else {
Some(std::time::Duration::from_secs(timeout))
};
let server_conf = ImapServerConf {
server_hostname: server_hostname.to_string(),
server_username: server_username.to_string(),
server_password: server_password.to_string(),
server_port,
use_starttls: true,
use_tls: true,
danger_accept_invalid_certs,
protocol: ImapProtocol::ManageSieve,
timeout,
};
let uid_store = Arc::new(UIDStore {
is_online: Arc::new(Mutex::new((
SystemTime::now(),
Err(MeliError::new("Account is uninitialised.")),
))),
..UIDStore::new(
account_hash,
Arc::new(account_name),
event_consumer,
server_conf.timeout,
)
});
Ok(ImapConnection::new_connection(&server_conf, uid_store))
}
impl ManageSieveConnection {
pub fn new(
account_hash: crate::backends::AccountHash,
account_name: String,
s: &AccountSettings,
event_consumer: crate::backends::BackendEventConsumer,
) -> Result<Self> {
let server_hostname = get_conf_val!(s["server_hostname"])?;
let server_username = get_conf_val!(s["server_username"])?;
let server_password = get_conf_val!(s["server_password"])?;
let server_port = get_conf_val!(s["server_port"], 4190)?;
let danger_accept_invalid_certs: bool =
get_conf_val!(s["danger_accept_invalid_certs"], false)?;
let timeout = get_conf_val!(s["timeout"], 16_u64)?;
let timeout = if timeout == 0 {
None
} else {
Some(std::time::Duration::from_secs(timeout))
};
let server_conf = ImapServerConf {
server_hostname: server_hostname.to_string(),
server_username: server_username.to_string(),
server_password: server_password.to_string(),
server_port,
use_starttls: true,
use_tls: true,
danger_accept_invalid_certs,
protocol: ImapProtocol::ManageSieve,
timeout,
};
let uid_store = Arc::new(UIDStore {
is_online: Arc::new(Mutex::new((
SystemTime::now(),
Err(MeliError::new("Account is uninitialised.")),
))),
..UIDStore::new(
account_hash,
Arc::new(account_name),
event_consumer,
server_conf.timeout,
)
});
Ok(Self {
inner: ImapConnection::new_connection(&server_conf, uid_store),
})
}
impl ManageSieve for ImapConnection {
fn havespace(&mut self) -> Result<()> {
pub async fn havespace(&mut self) -> Result<()> {
Ok(())
}
fn putscript(&mut self) -> Result<()> {
Ok(())
pub async fn putscript(&mut self, script_name: &[u8], script: &[u8]) -> Result<()> {
let mut ret = Vec::new();
self.inner
.send_literal(format!("Putscript {{{len}+}}\r\n", len = script_name.len()).as_bytes())
.await?;
self.inner.send_literal(script_name).await?;
self.inner
.send_literal(format!(" {{{len}+}}\r\n", len = script.len()).as_bytes())
.await?;
self.inner.send_literal(script).await?;
self.inner
.read_response(&mut ret, RequiredResponses::empty())
.await?;
let (_rest, response) = parser::response_oknobye(&ret)?;
match response {
ManageSieveResponse::Ok { .. } => Ok(()),
ManageSieveResponse::NoBye { code, message } => Err(format!(
"Could not upload script: {} {}",
code.map(|b| String::from_utf8_lossy(b)).unwrap_or_default(),
message
.map(|b| String::from_utf8_lossy(b))
.unwrap_or_default()
)
.into()),
}
}
fn listscripts(&mut self) -> Result<()> {
Ok(())
pub async fn listscripts(&mut self) -> Result<Vec<(Vec<u8>, bool)>> {
let mut ret = Vec::new();
self.inner.send_command(b"Listscripts").await?;
self.inner
.read_response(&mut ret, RequiredResponses::empty())
.await?;
let (_rest, scripts) =
parser::terminated(parser::listscripts, parser::tag_no_case(b"OK"))(&ret)?;
Ok(scripts
.into_iter()
.map(|(n, a)| (n.to_vec(), a))
.collect::<Vec<(Vec<u8>, bool)>>())
}
fn setactive(&mut self) -> Result<()> {
Ok(())
pub async fn checkscript(&mut self, script: &[u8]) -> Result<()> {
let mut ret = Vec::new();
self.inner
.send_literal(format!("Checkscript {{{len}+}}\r\n", len = script.len()).as_bytes())
.await?;
self.inner.send_literal(script).await?;
self.inner
.read_response(&mut ret, RequiredResponses::empty())
.await?;
let (_rest, response) = parser::response_oknobye(&ret)?;
match response {
ManageSieveResponse::Ok { .. } => Ok(()),
ManageSieveResponse::NoBye { code, message } => Err(format!(
"Checkscript reply: {} {}",
code.map(|b| String::from_utf8_lossy(b)).unwrap_or_default(),
message
.map(|b| String::from_utf8_lossy(b))
.unwrap_or_default()
)
.into()),
}
}
fn getscript(&mut self) -> Result<()> {
Ok(())
pub async fn setactive(&mut self, script_name: &[u8]) -> Result<()> {
let mut ret = Vec::new();
self.inner
.send_literal(format!("Setactive {{{len}+}}\r\n", len = script_name.len()).as_bytes())
.await?;
self.inner.send_literal(script_name).await?;
self.inner
.read_response(&mut ret, RequiredResponses::empty())
.await?;
let (_rest, response) = parser::response_oknobye(&ret)?;
match response {
ManageSieveResponse::Ok { .. } => Ok(()),
ManageSieveResponse::NoBye { code, message } => Err(format!(
"Could not set active script: {} {}",
code.map(|b| String::from_utf8_lossy(b)).unwrap_or_default(),
message
.map(|b| String::from_utf8_lossy(b))
.unwrap_or_default()
)
.into()),
}
}
fn deletescript(&mut self) -> Result<()> {
Ok(())
pub async fn getscript(&mut self, script_name: &[u8]) -> Result<Vec<u8>> {
let mut ret = Vec::new();
self.inner
.send_literal(format!("Getscript {{{len}+}}\r\n", len = script_name.len()).as_bytes())
.await?;
self.inner.send_literal(script_name).await?;
self.inner
.read_response(&mut ret, RequiredResponses::empty())
.await?;
if let Ok((_, ManageSieveResponse::NoBye { code, message })) =
parser::response_oknobye(&ret)
{
return Err(format!(
"Could not set active script: {} {}",
code.map(|b| String::from_utf8_lossy(b)).unwrap_or_default(),
message
.map(|b| String::from_utf8_lossy(b))
.unwrap_or_default()
)
.into());
}
let (_rest, script) =
parser::terminated(parser::getscript, parser::tag_no_case(b"OK"))(&ret)?;
Ok(script.to_vec())
}
fn renamescript(&mut self) -> Result<()> {
pub async fn deletescript(&mut self, script_name: &[u8]) -> Result<()> {
let mut ret = Vec::new();
self.inner
.send_literal(
format!("Deletescript {{{len}+}}\r\n", len = script_name.len()).as_bytes(),
)
.await?;
self.inner.send_literal(script_name).await?;
self.inner
.read_response(&mut ret, RequiredResponses::empty())
.await?;
let (_rest, response) = parser::response_oknobye(&ret)?;
match response {
ManageSieveResponse::Ok { .. } => Ok(()),
ManageSieveResponse::NoBye { code, message } => Err(format!(
"Could not delete script: {} {}",
code.map(|b| String::from_utf8_lossy(b)).unwrap_or_default(),
message
.map(|b| String::from_utf8_lossy(b))
.unwrap_or_default()
)
.into()),
}
}
pub async fn renamescript(&mut self) -> Result<()> {
Ok(())
}
}

@ -1626,12 +1626,12 @@ pub fn mailbox_token(input: &'_ [u8]) -> IResult<&'_ [u8], std::borrow::Cow<'_,
}
// astring = 1*ASTRING-CHAR / string
fn astring_token(input: &[u8]) -> IResult<&[u8], &[u8]> {
pub fn astring_token(input: &[u8]) -> IResult<&[u8], &[u8]> {
alt((string_token, astring_char))(input)
}
// string = quoted / literal
fn string_token(input: &[u8]) -> IResult<&[u8], &[u8]> {
pub fn string_token(input: &[u8]) -> IResult<&[u8], &[u8]> {
if let Ok((r, o)) = literal(input) {
return Ok((r, o));
}

@ -24,17 +24,15 @@ extern crate melib;
use melib::*;
use std::collections::VecDeque;
extern crate xdg_utils;
#[macro_use]
extern crate serde_derive;
extern crate linkify;
extern crate uuid;
extern crate serde_json;
extern crate smallvec;
extern crate termion;
use melib::backends::imap::managesieve::new_managesieve_connection;
use melib::backends::imap::managesieve::ManageSieveConnection;
use melib::Result;
#[macro_use]
@ -64,7 +62,7 @@ pub mod sqlite3;
pub mod jobs;
pub mod mailcap;
pub mod plugins;
//pub mod plugins;
use futures::executor::block_on;
@ -84,10 +82,7 @@ fn main() -> Result<()> {
std::process::exit(1);
}
let (config_path, account_name) = (
std::mem::replace(&mut args[0], String::new()),
std::mem::replace(&mut args[1], String::new()),
);
let (config_path, account_name) = (std::mem::take(&mut args[0]), std::mem::take(&mut args[1]));
std::env::set_var("MELI_CONFIG", config_path);
let settings = conf::Settings::new()?;
if !settings.accounts.contains_key(&account_name) {
@ -102,12 +97,47 @@ fn main() -> Result<()> {
);
std::process::exit(1);
}
let mut conn = new_managesieve_connection(&settings.accounts[&account_name].account)?;
block_on(conn.connect())?;
let mut res = String::with_capacity(8 * 1024);
let account = &settings.accounts[&account_name].account;
let mut conn = ManageSieveConnection::new(
0,
account_name.clone(),
account,
melib::backends::BackendEventConsumer::new(std::sync::Arc::new(|_, _| {})),
)?;
block_on(conn.inner.connect())?;
let mut input = String::new();
println!("managesieve shell: use 'logout'");
const AVAILABLE_COMMANDS: &[&str] = &[
"help",
"logout",
"listscripts",
"checkscript",
"putscript",
"setactive",
"getscript",
"deletescript",
];
const COMMANDS_HELP: &[&str] = &[
"help",
"logout",
"listscripts and whether they are active",
"paste a script to check for validity without uploading it",
"upload a script",
"set a script as active",
"get a script by its name",
"delete a script by its name",
];
println!("managesieve shell: use 'help' for available commands");
enum PrevCmd {
None,
Checkscript,
PutscriptName,
PutscriptString(String),
SetActiveName,
GetScriptName,
}
use PrevCmd::*;
let mut prev_cmd: PrevCmd = None;
loop {
use std::io;
use std::io::Write;
@ -116,12 +146,85 @@ fn main() -> Result<()> {
io::stdout().flush().unwrap();
match io::stdin().read_line(&mut input) {
Ok(_) => {
if input.trim().eq_ignore_ascii_case("logout") {
let input = input.trim();
if input.eq_ignore_ascii_case("logout") {
break;
}
block_on(conn.send_command(input.as_bytes()))?;
block_on(conn.read_lines(&mut res, String::new()))?;
println!("out: {}", res.trim());
if input.eq_ignore_ascii_case("help") {
println!("available commands: [{}]", AVAILABLE_COMMANDS.join(", "));
continue;
}
if input.len() >= "help ".len()
&& input[0.."help ".len()].eq_ignore_ascii_case("help ")
{
if let Some(i) = AVAILABLE_COMMANDS
.iter()
.position(|cmd| cmd.eq_ignore_ascii_case(&input["help ".len()..]))
{
println!("{}", COMMANDS_HELP[i]);
} else {
println!("invalid command `{}`", &input["help ".len()..]);
}
continue;
}
if input.eq_ignore_ascii_case("listscripts") {
let scripts = block_on(conn.listscripts())?;
println!("Got {} scripts:", scripts.len());
for (script, active) in scripts {
println!(
"{}active: {}",
if active { "" } else { "in" },
String::from_utf8_lossy(&script)
);
}
} else if input.eq_ignore_ascii_case("checkscript") {
prev_cmd = Checkscript;
println!("insert file path of script");
} else if input.eq_ignore_ascii_case("putscript") {
prev_cmd = PutscriptName;
println!("Insert script name");
} else if input.eq_ignore_ascii_case("setactive") {
prev_cmd = SetActiveName;
} else if input.eq_ignore_ascii_case("getscript") {
prev_cmd = GetScriptName;
} else if input.eq_ignore_ascii_case("deletescript") {
println!("unimplemented `{}`", input);
} else {
match prev_cmd {
None => println!("invalid command `{}`", input),
Checkscript => {
let content = std::fs::read_to_string(&input).unwrap();
let result = block_on(conn.checkscript(content.as_bytes()));
println!("Got {:?}", result);
prev_cmd = None;
}
PutscriptName => {
prev_cmd = PutscriptString(input.to_string());
println!("insert file path of script");
}
PutscriptString(name) => {
prev_cmd = None;
let content = std::fs::read_to_string(&input).unwrap();
let result =
block_on(conn.putscript(name.as_bytes(), content.as_bytes()));
println!("Got {:?}", result);
}
SetActiveName => {
prev_cmd = None;
let result = block_on(conn.setactive(input.as_bytes()));
println!("Got {:?}", result);
}
GetScriptName => {
prev_cmd = None;
let result = block_on(conn.getscript(input.as_bytes()));
println!("Got {:?}", result);
}
}
}
//block_on(conn.send_command(input.as_bytes()))?;
//block_on(conn.read_lines(&mut res, String::new()))?;
//println!("out: {}", res.trim());
}
Err(error) => println!("error: {}", error),
}

Loading…
Cancel
Save