diff --git a/CHANGELOG.md b/CHANGELOG.md index e6da7e62..d9fe32c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,10 @@ All notable changes to this project will be documented in this file. This projec ### Fixed - Invalid DKIM signatures for empty message bodies. -- Parsing IMAP `SEARCH BEFORE` commands. +- IMAP command `SEARCH BEFORE` is not properly parsed. +- IMAP response to `ENABLE` command misses enabled capabilities list. +- IMAP command `ENABLE QRESYNC` should also enable `CONDSTORE` extension. +- IMAP response to `FETCH ENVELOPE` should not return `NIL` when the `From` header is missing. ## [0.5.0] - 2023-12-27 diff --git a/crates/cli/src/modules/import.rs b/crates/cli/src/modules/import.rs index f8f0c237..aae5e999 100644 --- a/crates/cli/src/modules/import.rs +++ b/crates/cli/src/modules/import.rs @@ -336,9 +336,21 @@ impl ImportCommands { let mut retry_count = 0; loop { + // Sanitize message + let mut contents = + Vec::with_capacity(message.contents.len()); + let mut last_ch = 0; + for &ch in message.contents.iter() { + if ch == b'\n' && last_ch != b'\r' { + contents.push(b'\r'); + } + contents.push(ch); + last_ch = ch; + } + match client .email_import( - message.contents.clone(), + contents, [mailbox_id.as_ref()], if !message.flags.is_empty() { message diff --git a/crates/imap-proto/src/protocol/enable.rs b/crates/imap-proto/src/protocol/enable.rs index 3faf80da..990639e4 100644 --- a/crates/imap-proto/src/protocol/enable.rs +++ b/crates/imap-proto/src/protocol/enable.rs @@ -21,10 +21,32 @@ * for more details. */ -use super::capability::Capability; +use super::{capability::Capability, ImapResponse}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct Arguments { pub tag: String, pub capabilities: Vec, } + +pub struct Response { + pub enabled: Vec, +} + +impl ImapResponse for Response { + fn serialize(self) -> Vec { + if !self.enabled.is_empty() { + let mut buf = Vec::with_capacity(64); + buf.extend(b"* ENABLED"); + for capability in self.enabled { + buf.push(b' '); + capability.serialize(&mut buf); + } + buf.push(b'\r'); + buf.push(b'\n'); + buf + } else { + Vec::new() + } + } +} diff --git a/crates/imap-proto/src/protocol/fetch.rs b/crates/imap-proto/src/protocol/fetch.rs index 9d757c93..48884784 100644 --- a/crates/imap-proto/src/protocol/fetch.rs +++ b/crates/imap-proto/src/protocol/fetch.rs @@ -26,8 +26,8 @@ use std::borrow::Cow; use mail_parser::DateTime; use super::{ - literal_string, quoted_rfc2822_or_nil, quoted_string, quoted_string_or_nil, quoted_timestamp, - Flag, ImapResponse, Sequence, + literal_string, quoted_or_literal_string, quoted_or_literal_string_or_nil, + quoted_rfc2822_or_nil, quoted_timestamp, Flag, ImapResponse, Sequence, }; #[derive(Debug, Clone, PartialEq, Eq)] @@ -263,14 +263,14 @@ impl<'x> EmailAddress<'x> { pub fn serialize(&self, buf: &mut Vec) { buf.push(b'('); if let Some(name) = &self.name { - quoted_string(buf, name); + quoted_or_literal_string(buf, name); } else { buf.extend_from_slice(b"NIL"); } let addr = if let Some((route, addr)) = self.address.split_once(':') { buf.push(b' '); - quoted_string(buf, route); + quoted_or_literal_string(buf, route); buf.push(b' '); addr } else { @@ -279,11 +279,11 @@ impl<'x> EmailAddress<'x> { }; if let Some((local, host)) = addr.split_once('@') { - quoted_string(buf, local); + quoted_or_literal_string(buf, local); buf.push(b' '); - quoted_string(buf, host); + quoted_or_literal_string(buf, host); } else { - quoted_string(buf, &self.address); + quoted_or_literal_string(buf, &self.address); buf.extend_from_slice(b" \"\""); } buf.push(b')'); @@ -301,7 +301,7 @@ impl<'x> AddressGroup<'x> { pub fn serialize(&self, buf: &mut Vec) { buf.extend_from_slice(b"(NIL NIL "); if let Some(name) = &self.name { - quoted_string(buf, name); + quoted_or_literal_string(buf, name); } else { buf.extend_from_slice(b"\"\""); } @@ -338,7 +338,7 @@ impl<'x> BodyPart<'x> { part.serialize(buf, is_extended); } buf.push(b' '); - quoted_string(buf, body_subtype); + quoted_or_literal_string(buf, body_subtype); if is_extended { if let Some(body_parameters) = body_parameters { buf.extend_from_slice(b" ("); @@ -346,9 +346,9 @@ impl<'x> BodyPart<'x> { if pos > 0 { buf.push(b' '); } - quoted_string(buf, key); + quoted_or_literal_string(buf, key); buf.push(b' '); - quoted_string(buf, value); + quoted_or_literal_string(buf, value); } buf.push(b')'); } else { @@ -364,12 +364,12 @@ impl<'x> BodyPart<'x> { body_md5, extension, } => { - quoted_string_or_nil(buf, body_type.as_deref()); + quoted_or_literal_string_or_nil(buf, body_type.as_deref()); buf.push(b' '); fields.serialize(buf); if is_extended { buf.push(b' '); - quoted_string_or_nil(buf, body_md5.as_deref()); + quoted_or_literal_string_or_nil(buf, body_md5.as_deref()); buf.push(b' '); extension.serialize(buf); } @@ -386,7 +386,7 @@ impl<'x> BodyPart<'x> { buf.extend_from_slice(body_size_lines.to_string().as_bytes()); if is_extended { buf.push(b' '); - quoted_string_or_nil(buf, body_md5.as_deref()); + quoted_or_literal_string_or_nil(buf, body_md5.as_deref()); buf.push(b' '); extension.serialize(buf); } @@ -417,7 +417,7 @@ impl<'x> BodyPart<'x> { buf.extend_from_slice(body_size_lines.to_string().as_bytes()); if is_extended { buf.push(b' '); - quoted_string_or_nil(buf, body_md5.as_deref()); + quoted_or_literal_string_or_nil(buf, body_md5.as_deref()); buf.push(b' '); extension.serialize(buf); } @@ -501,16 +501,16 @@ impl<'x> BodyPart<'x> { impl<'x> BodyPartFields<'x> { pub fn serialize(&self, buf: &mut Vec) { - quoted_string_or_nil(buf, self.body_subtype.as_deref()); + quoted_or_literal_string_or_nil(buf, self.body_subtype.as_deref()); if let Some(body_parameters) = &self.body_parameters { buf.extend_from_slice(b" ("); for (pos, (key, value)) in body_parameters.iter().enumerate() { if pos > 0 { buf.push(b' '); } - quoted_string(buf, key); + quoted_or_literal_string(buf, key); buf.push(b' '); - quoted_string(buf, value); + quoted_or_literal_string(buf, value); } buf.push(b')'); } else { @@ -518,7 +518,7 @@ impl<'x> BodyPartFields<'x> { } for item in [&self.body_id, &self.body_description, &self.body_encoding] { buf.push(b' '); - quoted_string_or_nil(buf, item.as_deref()); + quoted_or_literal_string_or_nil(buf, item.as_deref()); } buf.push(b' '); buf.extend_from_slice(self.body_size_octets.to_string().as_bytes()); @@ -544,17 +544,21 @@ impl<'x> BodyPartExtension<'x> { pub fn serialize(&self, buf: &mut Vec) { if let Some((disposition, parameters)) = &self.body_disposition { buf.push(b'('); - quoted_string(buf, disposition); - buf.extend_from_slice(b" ("); - for (pos, (key, value)) in parameters.iter().enumerate() { - if pos > 0 { + quoted_or_literal_string(buf, disposition); + if !parameters.is_empty() { + buf.extend_from_slice(b" ("); + for (pos, (key, value)) in parameters.iter().enumerate() { + if pos > 0 { + buf.push(b' '); + } + quoted_or_literal_string(buf, key); buf.push(b' '); + quoted_or_literal_string(buf, value); } - quoted_string(buf, key); - buf.push(b' '); - quoted_string(buf, value); + buf.extend_from_slice(b"))"); + } else { + buf.extend_from_slice(b" NIL)"); } - buf.extend_from_slice(b"))"); } else { buf.extend_from_slice(b"NIL"); } @@ -563,7 +567,7 @@ impl<'x> BodyPartExtension<'x> { 0 => buf.extend_from_slice(b" NIL"), 1 => { buf.push(b' '); - quoted_string(buf, body_language.last().unwrap()); + quoted_or_literal_string(buf, body_language.last().unwrap()); } _ => { buf.extend_from_slice(b" ("); @@ -571,7 +575,7 @@ impl<'x> BodyPartExtension<'x> { if pos > 0 { buf.push(b' '); } - quoted_string(buf, lang); + quoted_or_literal_string(buf, lang); } buf.push(b')'); } @@ -580,7 +584,7 @@ impl<'x> BodyPartExtension<'x> { buf.extend_from_slice(b" NIL"); } buf.push(b' '); - quoted_string_or_nil(buf, self.body_location.as_deref()); + quoted_or_literal_string_or_nil(buf, self.body_location.as_deref()); } pub fn into_owned<'y>(self) -> BodyPartExtension<'y> { @@ -644,19 +648,35 @@ impl Section { } } +static DUMMY_ADDRESS: [Address; 1] = [Address::Single(EmailAddress { + name: None, + address: Cow::Borrowed("unknown@localhost"), +})]; + impl<'x> Envelope<'x> { pub fn serialize(&self, buf: &mut Vec) { buf.push(b'('); quoted_rfc2822_or_nil(buf, &self.date); buf.push(b' '); - quoted_string_or_nil(buf, self.subject.as_deref()); - self.serialize_addresses(buf, &self.from); + quoted_or_literal_string_or_nil(buf, self.subject.as_deref()); + + // Note: [RFC-2822] requires that all messages have a valid + // From header. Therefore, the from, sender, and reply-to + // members in the envelope can not be NIL. + + let from = if !self.from.is_empty() { + &self.from[..] + } else { + &DUMMY_ADDRESS[..] + }; + + self.serialize_addresses(buf, from); self.serialize_addresses( buf, if !self.sender.is_empty() { &self.sender } else { - &self.from + from }, ); self.serialize_addresses( @@ -664,7 +684,7 @@ impl<'x> Envelope<'x> { if !self.reply_to.is_empty() { &self.reply_to } else { - &self.from + from }, ); self.serialize_addresses(buf, &self.to); @@ -672,7 +692,7 @@ impl<'x> Envelope<'x> { self.serialize_addresses(buf, &self.bcc); for item in [&self.in_reply_to, &self.message_id] { buf.push(b' '); - quoted_string_or_nil(buf, item.as_deref()); + quoted_or_literal_string_or_nil(buf, item.as_deref()); } buf.push(b')'); } diff --git a/crates/imap-proto/src/protocol/mod.rs b/crates/imap-proto/src/protocol/mod.rs index 4e380d36..568bec97 100644 --- a/crates/imap-proto/src/protocol/mod.rs +++ b/crates/imap-proto/src/protocol/mod.rs @@ -179,6 +179,27 @@ pub fn quoted_string(buf: &mut Vec, text: &str) { buf.push(b'"'); } +pub fn quoted_or_literal_string(buf: &mut Vec, text: &str) { + if text + .as_bytes() + .iter() + .any(|ch| [b'\\', b'"', b'\r', b'\n'].contains(ch)) + { + literal_string(buf, text.as_bytes()) + } else { + buf.push(b'"'); + buf.extend_from_slice(text.as_bytes()); + buf.push(b'"'); + } +} +pub fn quoted_or_literal_string_or_nil(buf: &mut Vec, text: Option<&str>) { + if let Some(text) = text { + quoted_or_literal_string(buf, text); + } else { + buf.extend_from_slice(b"NIL"); + } +} + pub fn quoted_string_or_nil(buf: &mut Vec, text: Option<&str>) { if let Some(text) = text { quoted_string(buf, text); diff --git a/crates/imap/src/op/enable.rs b/crates/imap/src/op/enable.rs index 416eef2b..19593471 100644 --- a/crates/imap/src/op/enable.rs +++ b/crates/imap/src/op/enable.rs @@ -22,7 +22,7 @@ */ use imap_proto::{ - protocol::{capability::Capability, ProtocolVersion}, + protocol::{capability::Capability, enable, ImapResponse, ProtocolVersion}, receiver::Request, Command, StatusResponse, }; @@ -35,6 +35,9 @@ impl Session { pub async fn handle_enable(&mut self, request: Request) -> crate::OpResult { match request.parse_enable() { Ok(arguments) => { + let mut response = enable::Response { + enabled: Vec::with_capacity(arguments.capabilities.len()), + }; for capability in arguments.capabilities { match capability { Capability::IMAP4rev2 => { @@ -48,29 +51,20 @@ impl Session { } Capability::QResync => { self.is_qresync = true; + self.is_condstore = true; } Capability::Utf8Accept => {} _ => { - let mut buf = Vec::with_capacity(10); - capability.serialize(&mut buf); - self.write_bytes( - StatusResponse::ok(format!( - "{} cannot be enabled.", - String::from_utf8(buf).unwrap() - )) - .with_tag(arguments.tag) - .into_bytes(), - ) - .await?; - return Ok(()); + continue; } } + response.enabled.push(capability); } self.write_bytes( StatusResponse::ok("ENABLE successful.") .with_tag(arguments.tag) - .into_bytes(), + .serialize(response.serialize()), ) .await } diff --git a/crates/imap/src/op/fetch.rs b/crates/imap/src/op/fetch.rs index 0e71446e..eee3f085 100644 --- a/crates/imap/src/op/fetch.rs +++ b/crates/imap/src/op/fetch.rs @@ -269,16 +269,22 @@ impl SessionData { } if is_uid { - arguments.attributes.push_unique(Attribute::Uid); + if arguments.attributes.is_empty() { + arguments.attributes.push(Attribute::Flags); + } else if !arguments.attributes.contains(&Attribute::Uid) { + arguments.attributes.insert(0, Attribute::Uid); + } } let mut set_seen_ids = Vec::new(); // Process each message - for (id, imap_id) in ids { - let uid = imap_id.uid; - let seqnum = imap_id.seqnum; - + let mut ids = ids + .into_iter() + .map(|(id, imap_id)| (imap_id.seqnum, imap_id.uid, id)) + .collect::>(); + ids.sort_unstable_by_key(|(seqnum, _, _)| *seqnum); + for (seqnum, uid, id) in ids { // Obtain attributes and keywords let (email, keywords) = if let (Ok(Some(email)), Ok(Some(keywords))) = ( self.jmap diff --git a/crates/jmap/src/mailbox/set.rs b/crates/jmap/src/mailbox/set.rs index c4e81a1c..2bedb10c 100644 --- a/crates/jmap/src/mailbox/set.rs +++ b/crates/jmap/src/mailbox/set.rs @@ -58,6 +58,7 @@ use crate::{ JMAP, }; +#[allow(unused_imports)] use super::{UidMailbox, INBOX_ID, JUNK_ID, TRASH_ID}; struct SetContext<'x> { @@ -317,6 +318,14 @@ impl JMAP { remove_emails: bool, ) -> Result, MethodError> { // Internal folders cannot be deleted + #[cfg(feature = "test_mode")] + if [INBOX_ID, TRASH_ID].contains(&document_id) && !access_token.is_super_user() { + return Ok(Err(SetError::forbidden().with_description( + "You are not allowed to delete Inbox, Junk or Trash folders.", + ))); + } + + #[cfg(not(feature = "test_mode"))] if [INBOX_ID, TRASH_ID, JUNK_ID].contains(&document_id) && !access_token.is_super_user() { return Ok(Err(SetError::forbidden().with_description( "You are not allowed to delete Inbox, Junk or Trash folders.", diff --git a/tests/src/imap/fetch.rs b/tests/src/imap/fetch.rs index cfd71106..1df018e1 100644 --- a/tests/src/imap/fetch.rs +++ b/tests/src/imap/fetch.rs @@ -74,7 +74,7 @@ pub async fn test(imap: &mut ImapConnection, _imap_check: &mut ImapConnection) { "\"quoted-printable\" 228 3 \"3a942a99cdd8a099ae107d3867ec20fb\" NIL NIL NIL)", "(\"image\" \"gif\" (\"name\" \"Book about ☕ tables.gif\") ", "NIL NIL \"Base64\" 56 \"d40fa7f401e9dc2df56cbb740d65ff52\" ", - "(\"attachment\" ()) NIL NIL) \"mixed\" (\"boundary\" \"giddyup\") NIL NIL NIL)", + "(\"attachment\" NIL) NIL NIL) \"mixed\" (\"boundary\" \"giddyup\") NIL NIL NIL)", " 0 \"cdb0382a03a15601fb1b3c7422521620\" NIL NIL NIL) ", "\"mixed\" (\"boundary\" \"festivus\") NIL NIL NIL)" )); diff --git a/tests/src/imap/mod.rs b/tests/src/imap/mod.rs index c403a291..3c1c93b1 100644 --- a/tests/src/imap/mod.rs +++ b/tests/src/imap/mod.rs @@ -162,6 +162,7 @@ type = "elasticsearch" url = "https://localhost:9200" user = "elastic" password = "RtQ-Lu6+o4rxx=XJplVJ" +disable = true [store."elastic".tls] allow-invalid-certs = true @@ -170,6 +171,9 @@ allow-invalid-certs = true cert = "file://{CERT}" private-key = "file://{PK}" +[imap.protocol] +uidplus = true + [jmap] directory = "auth" @@ -319,7 +323,6 @@ async fn init_imap_tests(store_id: &str, delete_if_exists: bool) -> IMAPTest { lookup .create_test_user("admin", "secret", "Superuser") .await; - lookup.add_to_group("admin", "superuser").await; lookup .create_test_user_with_email("jdoe@example.com", "secret", "John Doe") .await;