From 84d93d65550a6e7b4053b872f769cc1e567abe4b Mon Sep 17 00:00:00 2001 From: Manos Pitsidianakis Date: Wed, 26 Jun 2024 18:08:46 +0300 Subject: [PATCH] melib/imap: add support for ID extension (opt-in) Signed-off-by: Manos Pitsidianakis --- meli/docs/meli.1 | 8 + meli/docs/meli.conf.5 | 7 + melib/Cargo.toml | 2 +- melib/src/email/parser.rs | 20 ++ melib/src/imap/connection.rs | 49 +++- melib/src/imap/mod.rs | 26 ++- melib/src/imap/protocol_parser.rs | 1 + melib/src/imap/protocol_parser/id_ext.rs | 273 +++++++++++++++++++++++ 8 files changed, 375 insertions(+), 11 deletions(-) create mode 100644 melib/src/imap/protocol_parser/id_ext.rs diff --git a/meli/docs/meli.1 b/meli/docs/meli.1 index 38e000d0..dea8c36e 100644 --- a/meli/docs/meli.1 +++ b/meli/docs/meli.1 @@ -752,6 +752,14 @@ Mailcap entries are searched for in the following files, in this order: .Re .It .Rs +.%B RFC2971 IMAP4 ID extension +.%I IETF +.%D October 01, 2000 +.%A Tim Showalter +.%U https://datatracker.ietf.org/doc/rfc2971/ +.Re +.It +.Rs .%B RFC3156 MIME Security with OpenPGP .%I IETF .%D August 01, 2001 diff --git a/meli/docs/meli.conf.5 b/meli/docs/meli.conf.5 index 53969204..7f0896f9 100644 --- a/meli/docs/meli.conf.5 +++ b/meli/docs/meli.conf.5 @@ -461,6 +461,13 @@ Use .Em AUTH=ANONYMOUS extension for authentication. .Pq Em false \" default value +.It Ic use_id Ar boolean +.Pq Em optional +Use +.Em ID +extension to retrieve server metadata, viewable at the account status page. +Enabling this does not send any information to the server. +.Pq Em false \" default value .It Ic timeout Ar integer .Pq Em optional Timeout to use for server connections in seconds. diff --git a/melib/Cargo.toml b/melib/Cargo.toml index cbe8d6f8..626b6da1 100644 --- a/melib/Cargo.toml +++ b/melib/Cargo.toml @@ -28,7 +28,7 @@ encoding_rs = { version = "^0.8" } flate2 = { version = "1.0.16" } futures = "0.3.5" -imap-codec = { version = "2.0.0-alpha.1", features = ["ext_condstore_qresync"], optional = true } +imap-codec = { version = "2.0.0-alpha.1", features = ["ext_condstore_qresync", "ext_id"], optional = true } indexmap = { version = "^1.5", default-features = false, features = ["serde-1"] } isahc = { version = "^1.7.2", optional = true, default-features = false, features = ["http2", "json", "text-decoding"] } diff --git a/melib/src/email/parser.rs b/melib/src/email/parser.rs index e05157d5..26554afd 100644 --- a/melib/src/email/parser.rs +++ b/melib/src/email/parser.rs @@ -133,6 +133,26 @@ impl<'i> ParsingError<&'i str> { backtrace: self.backtrace, } } + + pub fn new(input: &'i str, error: Cow<'static, str>) -> Self { + ParsingError { + input, + error, + #[cfg(any(test, doc))] + backtrace: Backtrace::capture(), + } + } +} + +impl<'i> ParsingError<&'i [u8]> { + pub fn new(input: &'i [u8], error: Cow<'static, str>) -> Self { + ParsingError { + input, + error, + #[cfg(any(test, doc))] + backtrace: Backtrace::capture(), + } + } } impl From<(I, &'static str)> for ParsingError { diff --git a/melib/src/imap/connection.rs b/melib/src/imap/connection.rs index 4b466a54..8b514998 100644 --- a/melib/src/imap/connection.rs +++ b/melib/src/imap/connection.rs @@ -53,7 +53,10 @@ use crate::{ email::parser::BytesExt, error::*, imap::{ - protocol_parser::{self, ImapLineSplit, ImapResponse, RequiredResponses, SelectResponse}, + protocol_parser::{ + self, id_ext::id_ext_response, ImapLineSplit, ImapResponse, RequiredResponses, + SelectResponse, + }, Capabilities, ImapServerConf, UIDStore, }, text::Truncate, @@ -100,6 +103,7 @@ pub struct ImapExtensionUse { pub idle: bool, pub deflate: bool, pub oauth2: bool, + pub id: bool, } impl Default for ImapExtensionUse { @@ -110,6 +114,7 @@ impl Default for ImapExtensionUse { idle: true, deflate: true, oauth2: false, + id: false, } } } @@ -510,15 +515,40 @@ impl ImapStream { } } - if got_new_capabilities { - return Ok((capabilities, ret)); + if !got_new_capabilities { + // sending CAPABILITY after LOGIN automatically is an RFC recommendation, so + // check for lazy servers. + ret.send_command(CommandBody::Capability).await?; + ret.read_response(&mut res).await?; + capabilities + .extend(parse_capabilities(&res, &server_conf.server_hostname)?.into_iter()); } - // sending CAPABILITY after LOGIN automatically is an RFC recommendation, so - // check for lazy servers. - ret.send_command(CommandBody::Capability).await?; - ret.read_response(&mut res).await?; - capabilities.extend(parse_capabilities(&res, &server_conf.server_hostname)?.into_iter()); + if matches!( + server_conf.protocol, + ImapProtocol::IMAP { + extension_use: ImapExtensionUse { id: true, .. }, + .. + } + ) && capabilities.contains(b"ID".as_slice()) + { + ret.send_command(CommandBody::Id { parameters: None }) + .await?; + ret.read_response(&mut res).await?; + match id_ext_response(&res) { + Err(err) => { + log::warn!( + "Could not parse ID command response from server. Consider turning ID use \ + off. Error was: {}", + err + ); + } + Ok((_, None)) => {} + Ok((_, res @ Some(_))) => { + *uid_store.server_id.lock().unwrap() = res; + } + } + } Ok((capabilities, ret)) } @@ -762,9 +792,10 @@ impl ImapConnection { ImapExtensionUse { condstore, deflate, - idle: _idle, + idle: _, oauth2: _, auth_anonymous: _, + id: _, }, } => { if capabilities.contains(&b"CONDSTORE"[..]) && condstore { diff --git a/melib/src/imap/mod.rs b/melib/src/imap/mod.rs index 7f467e18..3b5ce976 100644 --- a/melib/src/imap/mod.rs +++ b/melib/src/imap/mod.rs @@ -53,6 +53,8 @@ use std::{ time::{Duration, SystemTime}, }; +use protocol_parser::id_ext::IDResponse; + pub extern crate imap_codec; pub use cache::ModSequence; use futures::{lock::Mutex as FutureMutex, stream::Stream}; @@ -84,6 +86,7 @@ pub static SUPPORTED_CAPABILITIES: &[&str] = &[ "COMPRESS=DEFLATE", "CONDSTORE", "ENABLE", + "ID", "IDLE", "IMAP4REV1", "LIST-EXTENDED", @@ -150,6 +153,7 @@ macro_rules! get_conf_val { pub struct UIDStore { pub account_hash: AccountHash, pub account_name: Arc, + pub server_id: Arc>>, pub keep_offline_cache: Arc>, pub capabilities: Arc>, pub hash_index: Arc>>, @@ -181,6 +185,7 @@ impl UIDStore { Self { account_hash, account_name, + server_id: Default::default(), keep_offline_cache: Arc::new(Mutex::new(false)), capabilities: Default::default(), uidvalidity: Default::default(), @@ -259,6 +264,7 @@ impl MailBackend for ImapType { condstore, oauth2, auth_anonymous, + id, }, } = self.server_conf.protocol { @@ -326,6 +332,15 @@ impl MailBackend for ImapType { }; } } + "ID" => { + if id { + *status = MailBackendExtensionStatus::Enabled { comment: None }; + } else { + *status = MailBackendExtensionStatus::Supported { + comment: Some("Disabled by user configuration"), + }; + } + } _ => { if SUPPORTED_CAPABILITIES .iter() @@ -338,6 +353,13 @@ impl MailBackend for ImapType { } } extensions.sort_by(|a, b| a.0.cmp(&b.0)); + let metadata = self + .uid_store + .server_id + .lock() + .unwrap() + .as_ref() + .and_then(|id| serde_json::to_value(id).ok()); MailBackendCapabilities { is_async: true, is_remote: true, @@ -346,7 +368,7 @@ impl MailBackend for ImapType { supports_tags: true, supports_submission: false, extra_submission_headers: &[], - metadata: None, + metadata, } } @@ -1369,6 +1391,7 @@ impl ImapType { deflate: get_conf_val!(s["use_deflate"], true)?, oauth2: use_oauth2, auth_anonymous: get_conf_val!(s["use_auth_anonymous"], false)?, + id: get_conf_val!(s["use_id"], false)?, }, }, timeout, @@ -1639,6 +1662,7 @@ impl ImapType { get_conf_val!(s["use_condstore"], true)?; get_conf_val!(s["use_deflate"], true)?; get_conf_val!(s["use_auth_anonymous"], false)?; + get_conf_val!(s["use_id"], false)?; let _timeout = get_conf_val!(s["timeout"], 16_u64)?; let extra_keys = s .extra diff --git a/melib/src/imap/protocol_parser.rs b/melib/src/imap/protocol_parser.rs index b352a02a..26b36e65 100644 --- a/melib/src/imap/protocol_parser.rs +++ b/melib/src/imap/protocol_parser.rs @@ -23,6 +23,7 @@ use std::{convert::TryFrom, str::FromStr}; +pub mod id_ext; #[cfg(test)] mod tests; diff --git a/melib/src/imap/protocol_parser/id_ext.rs b/melib/src/imap/protocol_parser/id_ext.rs new file mode 100644 index 00000000..4622a987 --- /dev/null +++ b/melib/src/imap/protocol_parser/id_ext.rs @@ -0,0 +1,273 @@ +// +// meli +// +// Copyright 2024 Emmanouil Pitsidianakis +// +// This file is part of meli. +// +// meli is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// meli is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with meli. If not, see . +// +// SPDX-License-Identifier: EUPL-1.2 OR GPL-3.0-or-later + +//! # Parsing `ID` extension command queries and responses +//! +//! This module provides a parsing function for the parameter list of the `ID` +//! command arguments and the `ID` command response. +//! +//! The `ID` extension is defined in [RFC2971](https://datatracker.ietf.org/doc/rfc2971/). + +use indexmap::IndexMap; +use nom::bytes::complete::tag; + +use super::{quoted, quoted_or_nil}; +use crate::email::parser::{BytesExt, IResult}; + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)] +pub struct IDResponse { + #[serde(skip_serializing_if = "Option::is_none")] + /// Name of the program + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Version number of the program + pub version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Name of the operating system + pub os: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Version of the operating system + pub os_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Vendor of the client/server + pub vendor: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// URL to contact for support + pub support_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Postal address of contact/vendor + pub address: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Date program was released, specified as a date-time in `IMAP4rev1` + pub date: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Command used to start the program + pub command: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Arguments supplied on the command line, if any if any + pub arguments: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Description of environment, i.e., UNIX environment variables or Windows + /// registry settings + pub environment: Option, + #[serde(skip_serializing_if = "IndexMap::is_empty")] + pub extra: IndexMap>, +} + +pub fn id_ext_params_list(input: &[u8]) -> IResult<&[u8], Option> { + // id_params_list ::= "(" #(string SPACE nstring) ")" / nil ;; list of field + // value pairs + + let is_nil: IResult<&[u8], _> = tag("NIL")(input); + if let Ok((input, _)) = is_nil { + return Ok((input, None)); + } + + let mut retval = IDResponse::default(); + + let (mut input, _) = tag("(")(input)?; + + let mut hits = 0; + // Implementations MUST NOT send more than 30 field-value pairs. + for _ in 0..30 { + let (_input, mut field) = quoted(input)?; + // Strings are not case-sensitive. + if field.len() > 30 { + return Err(nom::Err::Error( + ( + input, + "id_ext_params_list(): Field strings MUST NOT be longer than 30 octets.", + ) + .into(), + )); + } + for byte in field.iter_mut() { + byte.make_ascii_lowercase(); + } + + let Ok(field) = String::from_utf8(field) else { + return Err(nom::Err::Error( + (input, "id_ext_params_list(): invalid field value.").into(), + )); + }; + let (_input, _) = tag(" ")(_input)?; + let (_input, value) = quoted_or_nil(_input)?; + let value = if let Some(value) = value { + // Value strings MUST NOT be longer than 1024 octets. + if value.len() > 1024 { + return Err(nom::Err::Error( + ( + input, + "id_ext_params_list(): Field value strings MUST NOT be longer than 1024 \ + octets.", + ) + .into(), + )); + } + let Ok(value) = String::from_utf8(value) else { + return Err(nom::Err::Error( + (input, "id_ext_params_list(): invalid value content.").into(), + )); + }; + hits += 1; + Some(value) + } else { + None + }; + macro_rules! field_name { + ($field:ident) => {{ + if retval.$field.is_some() { + // Implementations MUST NOT send the same field name more than once. + return Err(nom::Err::Error( + (input, "id_ext_params_list(): Repeated field value.").into(), + )); + } + retval.$field = value; + }}; + } + match field.as_str() { + "name" => field_name! { name }, + "version" => field_name! { version }, + "os" => field_name! { os }, + "os-version" => field_name! { os_version }, + "vendor" => field_name! { vendor }, + "support-url" => field_name! { support_url }, + "address" => field_name! { address }, + "date" => field_name! { date }, + "command" => field_name! { command }, + "arguments" => field_name! { arguments }, + "environment" => field_name! { environment }, + _ => { + // Implementations MUST NOT send the same field name more than once. + if retval.extra.contains_key(field.as_str()) { + return Err(nom::Err::Error( + (input, "id_ext_params_list(): Repeated field value.").into(), + )); + } + retval.extra.insert(field, value); + } + } + input = _input; + if input.starts_with(b")") { + break; + } + let (_input, _) = tag(" ")(input)?; + input = _input; + } + + let (input, _) = tag(")")(input)?; + + Ok(( + input, + if hits == 0 && retval.extra.is_empty() { + None + } else { + Some(retval) + }, + )) +} + +pub fn id_ext_response(input: &[u8]) -> IResult<&[u8], Option> { + // id_response ::= "ID" SPACE id_params_list + let (input, _) = tag("* ID ")(input.ltrim())?; + id_ext_params_list(input) +} + +#[test] +fn test_imap_id_ext() { + assert_eq!( + id_ext_response(br#" * ID ("name" "Cyrus" "version" "1.5" "os" "sunos" "os-version" "5.5" "support-url" "mailto:cyrus-bugs+@andrew.cmu.edu")"#).unwrap(), + ( + b"".as_slice(), + Some(IDResponse { + name: Some("Cyrus".to_string()), + version: Some("1.5".to_string()), + os: Some("sunos".to_string()), + os_version: Some("5.5".to_string()), + support_url: Some("mailto:cyrus-bugs+@andrew.cmu.edu".to_string()), + ..Default::default() + }) + ) + ); + assert_eq!( + id_ext_response(b"* ID NIL").unwrap(), + (b"".as_slice(), None) + ); + assert_eq!( + id_ext_params_list(b"NIL").unwrap(), + id_ext_response(b"* ID NIL").unwrap(), + ); + assert_eq!( + id_ext_response(br#" * ID ("name" "Cyrus" "version" NIL)"#).unwrap(), + ( + b"".as_slice(), + Some(IDResponse { + name: Some("Cyrus".to_string()), + ..Default::default() + }) + ) + ); + assert_eq!( + id_ext_response(br#" * ID ("foo" "Example" "bar" NIL "name" "Cyrus")"#).unwrap(), + ( + b"".as_slice(), + Some(IDResponse { + name: Some("Cyrus".to_string()), + extra: indexmap::indexmap! { + "foo".to_string() => Some("Example".to_string()), + "bar".to_string() => None, + }, + ..Default::default() + }) + ) + ); + // Errors: + let repeated_field_value: &[u8] = br#" * ID ("name" "Cyrus" "name" NIL)"#; + assert_eq!( + id_ext_response(repeated_field_value).unwrap_err(), + nom::Err::Error(crate::email::parser::ParsingError::<&[u8]>::new( + &repeated_field_value[22..], + std::borrow::Cow::Borrowed("id_ext_params_list(): Repeated field value.") + )) + ); + let field_over_than_30_octets: &[u8] = + br#" * ID ("aktinochrysofaidrovrontolamprofengofotostolistos" NIL)"#; + assert_eq!( + id_ext_response(field_over_than_30_octets).unwrap_err(), + nom::Err::Error(crate::email::parser::ParsingError::<&[u8]>::new( + &field_over_than_30_octets[7..], + std::borrow::Cow::Borrowed( + "id_ext_params_list(): Field strings MUST NOT be longer than 30 octets." + ) + )) + ); + let value_over_1024_octets: &[u8] = br#" * ID ("song" "In Spite of Ourselves" "lyrics" "She don't like her eggs all runny She thinks crossin' her legs is funny She looks down her nose at money She gets it on like the Easter Bunny She's my baby, I'm her honey I'm never gonna let her go He ain't got laid in a month of Sundays I caught him once and he was sniffin' my undies He ain't too sharp but he gets things done Drinks his beer like it's oxygen He's my baby, and I'm his honey Never gonna let him go In spite of ourselves We'll end up a-sittin' on a rainbow Against all odds Honey, we're the big door prize We're gonna spite Our noses right off of our faces There won't be nothin' but big old hearts Dancin' in our eyes She thinks all my jokes are corny Convict movies make her horny She likes ketchup on her scrambled eggs Swears like a sailor when she shaves her legs She takes a lickin' and keeps on tickin' I'm never gonna let her go He's got more balls than a big brass monkey He's a wacked out werido and a lovebug junkie Sly as a fox and crazy as a loon Payday comes and he's howlin' at the moon He's my baby, I don't mean maybe Never gonna let him go In spite of ourselves We'll end up a-sittin' on a rainbow Against all odds Honey, we're the big door prize We're gonna spite Our noses right off of our faces There won't be nothin' but big old hearts Dancin' in our eyes In spite of ourselves We'll end up a-sittin' on a rainbow Against all odds Honey, we're the big door prize We're gonna spite Our noses right off of our faces There won't be nothin' but big old hearts Dancin' in our eyes There won't be nothin' but big old hearts Dancin' in our eyes In spite of ourselves")"#; + assert_eq!( + id_ext_response(value_over_1024_octets).unwrap_err(), + nom::Err::Error(crate::email::parser::ParsingError::<&[u8]>::new( + &value_over_1024_octets[38..], + std::borrow::Cow::Borrowed( + "id_ext_params_list(): Field value strings MUST NOT be longer than 1024 octets." + ) + )) + ); +}