melib/imap: add support for ID extension (opt-in)

Signed-off-by: Manos Pitsidianakis <manos@pitsidianak.is>
pull/425/head
Manos Pitsidianakis 2 months ago
parent af6838c20c
commit 84d93d6555
No known key found for this signature in database
GPG Key ID: 7729C7707F7E09D0

@ -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

@ -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.

@ -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"] }

@ -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<I> From<(I, &'static str)> for ParsingError<I> {

@ -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 {

@ -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<str>,
pub server_id: Arc<Mutex<Option<IDResponse>>>,
pub keep_offline_cache: Arc<Mutex<bool>>,
pub capabilities: Arc<Mutex<Capabilities>>,
pub hash_index: Arc<Mutex<HashMap<EnvelopeHash, (UID, MailboxHash)>>>,
@ -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

@ -23,6 +23,7 @@
use std::{convert::TryFrom, str::FromStr};
pub mod id_ext;
#[cfg(test)]
mod tests;

@ -0,0 +1,273 @@
//
// meli
//
// Copyright 2024 Emmanouil Pitsidianakis <manos@pitsidianak.is>
//
// 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 <http://www.gnu.org/licenses/>.
//
// 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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
/// Version number of the program
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
/// Name of the operating system
pub os: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
/// Version of the operating system
pub os_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
/// Vendor of the client/server
pub vendor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
/// URL to contact for support
pub support_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
/// Postal address of contact/vendor
pub address: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
/// Date program was released, specified as a date-time in `IMAP4rev1`
pub date: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
/// Command used to start the program
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
/// Arguments supplied on the command line, if any if any
pub arguments: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
/// Description of environment, i.e., UNIX environment variables or Windows
/// registry settings
pub environment: Option<String>,
#[serde(skip_serializing_if = "IndexMap::is_empty")]
pub extra: IndexMap<String, Option<String>>,
}
pub fn id_ext_params_list(input: &[u8]) -> IResult<&[u8], Option<IDResponse>> {
// 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<IDResponse>> {
// 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."
)
))
);
}
Loading…
Cancel
Save