diff --git a/docs/meli.conf.5 b/docs/meli.conf.5 index 53e76679..68683178 100644 --- a/docs/meli.conf.5 +++ b/docs/meli.conf.5 @@ -346,6 +346,56 @@ Example: format = "mbox" mailboxes."Python mailing list" = { path = "~/.mail/python.mbox", subscribe = true, autoload = true } .Ed +.Ss NNTP +NNTP specific options +.Bl -tag -width 36n +.It Ic server_hostname Ar String +example: +.Qq nntp.example.com +.It Ic server_username Ar String +Server username +.It Ic server_password Ar String +Server password +.It Ic require_auth Ar bool +.Pq Em optional +require authentication in every case +.\" default value +.Pq Em true +.It Ic use_tls Ar boolean +.Pq Em optional +Connect with TLS. +.\" default value +.Pq Em false +.It Ic server_port Ar number +.Pq Em optional +The port to connect to +.\" default value +.Pq Em 119 +.It Ic danger_accept_invalid_certs Ar boolean +.Pq Em optional +Do not validate TLS certificates. +.\" default value +.Pq Em false +.El +.Pp +You have to explicitly state the groups you want to see in the +.Ic mailboxes +field. +Example: +.Bd -literal +[accounts.sicpm.mailboxes] + "sic.all" = {} +.Ed +.Pp +To submit articles directly to the NNTP server, you must set the special value +.Em server_submission +in the +.Ic send_mail +field. +Example: +.Bd -literal +composing.send_mail = "server_submission" +.Ed .Ss MAILBOXES .Bl -tag -width 36n .It Ic alias Ar String diff --git a/melib/build.rs b/melib/build.rs index 7fa5ad96..8ca4fd6d 100644 --- a/melib/build.rs +++ b/melib/build.rs @@ -43,7 +43,6 @@ fn main() -> Result<(), std::io::Error> { const EMOJI_DATA_URL: &str = "https://www.unicode.org/Public/UCD/latest/ucd/emoji/emoji-data.txt"; - let mod_path = Path::new(MOD_PATH); if mod_path.exists() { eprintln!( diff --git a/melib/src/backends.rs b/melib/src/backends.rs index 24a2d7f3..510e1bf6 100644 --- a/melib/src/backends.rs +++ b/melib/src/backends.rs @@ -401,6 +401,15 @@ pub trait MailBackend: ::std::fmt::Debug + Send + Sync { ) -> ResultFuture> { Err(MeliError::new("Unimplemented.")) } + + fn submit( + &self, + bytes: Vec, + mailbox_hash: Option, + flags: Option, + ) -> ResultFuture<()> { + Err(MeliError::new("Not supported in this backend.")) + } } /// A `BackendOp` manages common operations for the various mail backends. They only live for the diff --git a/melib/src/backends/imap/connection.rs b/melib/src/backends/imap/connection.rs index 9b1ce79a..f9539724 100644 --- a/melib/src/backends/imap/connection.rs +++ b/melib/src/backends/imap/connection.rs @@ -588,7 +588,9 @@ impl ImapConnection { pub fn connect<'a>(&'a mut self) -> Pin> + Send + 'a>> { Box::pin(async move { if let (time, ref mut status @ Ok(())) = *self.uid_store.is_online.lock().unwrap() { - if SystemTime::now().duration_since(time).unwrap_or_default() >= IMAP_PROTOCOL_TIMEOUT { + if SystemTime::now().duration_since(time).unwrap_or_default() + >= IMAP_PROTOCOL_TIMEOUT + { let err = MeliError::new("Connection timed out").set_kind(ErrorKind::Timeout); *status = Err(err.clone()); self.stream = Err(err); @@ -978,45 +980,38 @@ impl ImapConnection { pub async fn unselect(&mut self) -> Result<()> { match self.stream.as_mut()?.current_mailbox.take() { - MailboxSelection::Examine(_) | - MailboxSelection::Select(_) => { - let mut response = Vec::with_capacity(8 * 1024); - if self - .uid_store - .capabilities - .lock() - .unwrap() - .iter() - .any(|cap| cap.eq_ignore_ascii_case(b"UNSELECT")) + MailboxSelection::Examine(_) | MailboxSelection::Select(_) => { + let mut response = Vec::with_capacity(8 * 1024); + if self + .uid_store + .capabilities + .lock() + .unwrap() + .iter() + .any(|cap| cap.eq_ignore_ascii_case(b"UNSELECT")) + { + self.send_command(b"UNSELECT").await?; + self.read_response(&mut response, RequiredResponses::empty()) + .await?; + } else { + /* `RFC3691 - UNSELECT Command` states: "[..] IMAP4 provides this + * functionality (via a SELECT command with a nonexistent mailbox name or + * reselecting the same mailbox with EXAMINE command)[..] + */ + let mut nonexistent = "blurdybloop".to_string(); { - self.send_command(b"UNSELECT").await?; - self.read_response(&mut response, RequiredResponses::empty()) - .await?; - } else { - /* `RFC3691 - UNSELECT Command` states: "[..] IMAP4 provides this - * functionality (via a SELECT command with a nonexistent mailbox name or - * reselecting the same mailbox with EXAMINE command)[..] - */ - let mut nonexistent = "blurdybloop".to_string(); - { - let mailboxes = self.uid_store.mailboxes.lock().await; - while mailboxes.values().any(|m| m.imap_path() == nonexistent) { - nonexistent.push('p'); - } - } - self.send_command( - format!( - "SELECT \"{}\"", - nonexistent - ) - .as_bytes(), - ) - .await?; - self.read_response(&mut response, RequiredResponses::NO_REQUIRED) - .await?; + let mailboxes = self.uid_store.mailboxes.lock().await; + while mailboxes.values().any(|m| m.imap_path() == nonexistent) { + nonexistent.push('p'); } + } + self.send_command(format!("SELECT \"{}\"", nonexistent).as_bytes()) + .await?; + self.read_response(&mut response, RequiredResponses::NO_REQUIRED) + .await?; } - MailboxSelection::None => {}, + } + MailboxSelection::None => {} } Ok(()) } diff --git a/melib/src/backends/nntp.rs b/melib/src/backends/nntp.rs index c3176e26..f6363f5e 100644 --- a/melib/src/backends/nntp.rs +++ b/melib/src/backends/nntp.rs @@ -131,6 +131,7 @@ impl MailBackend for NntpType { ) }) .collect::>(); + let mut supports_submission = false; let NntpExtensionUse { #[cfg(feature = "deflate_compression")] deflate, @@ -138,6 +139,10 @@ impl MailBackend for NntpType { { for (name, status) in extensions.iter_mut() { match name.as_str() { + s if s.eq_ignore_ascii_case("POST") => { + supports_submission = true; + *status = MailBackendExtensionStatus::Enabled { comment: None }; + } "COMPRESS DEFLATE" => { #[cfg(feature = "deflate_compression")] { @@ -171,7 +176,7 @@ impl MailBackend for NntpType { supports_search: false, extensions: Some(extensions), supports_tags: false, - supports_submission: false, + supports_submission, } } @@ -354,6 +359,39 @@ impl MailBackend for NntpType { ) -> ResultFuture> { Err(MeliError::new("Unimplemented.")) } + + fn submit( + &self, + bytes: Vec, + mailbox_hash: Option, + flags: Option, + ) -> ResultFuture<()> { + let connection = self.connection.clone(); + Ok(Box::pin(async move { + match timeout(Some(Duration::from_secs(60 * 16)), connection.lock()).await { + Ok(mut conn) => { + match &conn.stream { + Ok(stream) => { + if !stream.supports_submission { + return Err(MeliError::new("Server prohibits posting.")); + } + } + Err(err) => return Err(err.clone()), + } + let mut res = String::with_capacity(8 * 1024); + if let Some(mailbox_hash) = mailbox_hash { + conn.select_group(mailbox_hash, false, &mut res).await?; + } + conn.send_command(b"POST").await?; + conn.read_response(&mut res, false, &["340 "]).await?; + conn.send_multiline_data_block(&bytes).await?; + conn.read_response(&mut res, false, &["240 "]).await?; + Ok(()) + } + Err(err) => Err(err), + } + })) + } } impl NntpType { diff --git a/melib/src/backends/nntp/connection.rs b/melib/src/backends/nntp/connection.rs index 0db33caf..3cd0e380 100644 --- a/melib/src/backends/nntp/connection.rs +++ b/melib/src/backends/nntp/connection.rs @@ -45,7 +45,7 @@ impl Default for NntpExtensionUse { fn default() -> Self { Self { #[cfg(feature = "deflate_compression")] - deflate: true, + deflate: false, } } } @@ -55,6 +55,7 @@ pub struct NntpStream { pub stream: AsyncWrapper, pub extension_use: NntpExtensionUse, pub current_mailbox: MailboxSelection, + pub supports_submission: bool, } #[derive(Debug, Copy, Clone, Eq, PartialEq)] @@ -100,6 +101,7 @@ impl NntpStream { stream, extension_use: server_conf.extension_use, current_mailbox: MailboxSelection::None, + supports_submission: false, }; if server_conf.use_tls { @@ -114,6 +116,8 @@ impl NntpStream { if server_conf.use_starttls { ret.read_response(&mut res, false, &["200 ", "201 "]) .await?; + ret.supports_submission = res.starts_with("200"); + ret.send_command(b"CAPABILITIES").await?; ret.read_response(&mut res, true, command_to_replycodes("CAPABILITIES")) .await?; @@ -193,9 +197,6 @@ impl NntpStream { .chain_err_summary(|| format!("Could not initiate TLS negotiation to {}.", path)) .chain_err_kind(crate::error::ErrorKind::Network)?; } - } else { - ret.read_response(&mut res, false, &["200 ", "201 "]) - .await?; } //ret.send_command( // format!( @@ -216,6 +217,9 @@ impl NntpStream { ); } + ret.read_response(&mut res, false, &["200 ", "201 "]) + .await?; + ret.supports_submission = res.starts_with("200"); ret.send_command(b"CAPABILITIES").await?; ret.read_response(&mut res, true, command_to_replycodes("CAPABILITIES")) .await?; @@ -225,7 +229,8 @@ impl NntpStream { &server_conf.server_hostname, res ))); } - let capabilities: HashSet = res.lines().skip(1).map(|l| l.to_string()).collect(); + let capabilities: HashSet = + res.lines().skip(1).map(|l| l.trim().to_string()).collect(); if !capabilities .iter() .any(|cap| cap.eq_ignore_ascii_case("VERSION 2")) @@ -235,6 +240,12 @@ impl NntpStream { &server_conf.server_hostname ))); } + if !capabilities + .iter() + .any(|cap| cap.eq_ignore_ascii_case("POST")) + { + ret.supports_submission = false; + } if server_conf.require_auth { if capabilities.iter().any(|c| c.starts_with("AUTHINFO USER")) { @@ -280,6 +291,7 @@ impl NntpStream { stream, extension_use, current_mailbox, + supports_submission, } = ret; let stream = stream.into_inner()?; return Ok(( @@ -288,6 +300,7 @@ impl NntpStream { stream: AsyncWrapper::new(stream.deflate())?, extension_use, current_mailbox, + supports_submission, }, )); } @@ -364,6 +377,9 @@ impl NntpStream { } pub async fn send_command(&mut self, command: &[u8]) -> Result<()> { + debug!("sending: {}", unsafe { + std::str::from_utf8_unchecked(command) + }); if let Err(err) = try_await(async move { let command = command.trim(); self.stream.write_all(command).await?; @@ -383,13 +399,24 @@ impl NntpStream { } } - pub async fn send_multiline_data_block(&mut self, data: &str) -> Result<()> { + pub async fn send_multiline_data_block(&mut self, data: &[u8]) -> Result<()> { if let Err(err) = try_await(async move { - for l in data.lines() { - if l.starts_with('.') { + let mut ptr = 0; + while let Some(pos) = data[ptr..].find("\n") { + let l = &data[ptr..ptr + pos].trim_end(); + if l.starts_with(b".") { self.stream.write_all(b".").await?; } - self.stream.write_all(l.as_bytes()).await?; + self.stream.write_all(l).await?; + self.stream.write_all(b"\r\n").await?; + ptr += pos + 1; + } + let l = &data[ptr..].trim_end(); + if !l.is_empty() { + if l.starts_with(b".") { + self.stream.write_all(b".").await?; + } + self.stream.write_all(l).await?; self.stream.write_all(b"\r\n").await?; } self.stream.write_all(b".\r\n").await?; @@ -523,7 +550,7 @@ impl NntpConnection { Ok(()) } - pub async fn send_multiline_data_block(&mut self, message: &str) -> Result<()> { + pub async fn send_multiline_data_block(&mut self, message: &[u8]) -> Result<()> { self.stream .as_mut()? .send_multiline_data_block(message) diff --git a/melib/src/text_processing/line_break.rs b/melib/src/text_processing/line_break.rs index ed052f2b..46490426 100644 --- a/melib/src/text_processing/line_break.rs +++ b/melib/src/text_processing/line_break.rs @@ -1246,7 +1246,6 @@ easy to take MORE than nothing.'"#; } } - mod segment_tree { /*! Simple segment tree implementation for maximum in range queries. This is useful if given an * array of numbers you want to get the maximum value inside an interval quickly. diff --git a/melib/src/text_processing/tables.rs b/melib/src/text_processing/tables.rs index 234074bb..93a9d273 100644 --- a/melib/src/text_processing/tables.rs +++ b/melib/src/text_processing/tables.rs @@ -3455,15 +3455,9 @@ pub const LINE_BREAK_RULES: &[(u32, u32, LineBreakClass)] = &[ (0x100000, 0x10FFFD, XX), ]; -pub const ASCII: &[(u32, u32)] = &[ - (0x20, 0x7E), -]; +pub const ASCII: &[(u32, u32)] = &[(0x20, 0x7E)]; -pub const PRIVATE: &[(u32, u32)] = &[ - (0xE000, 0xF8FF), - (0xF0000, 0xFFFFD), - (0x100000, 0x10FFFD), -]; +pub const PRIVATE: &[(u32, u32)] = &[(0xE000, 0xF8FF), (0xF0000, 0xFFFFD), (0x100000, 0x10FFFD)]; pub const NONPRINT: &[(u32, u32)] = &[ (0x0, 0x1F), diff --git a/src/components/mail/compose.rs b/src/components/mail/compose.rs index 91cca55f..a64687c2 100644 --- a/src/components/mail/compose.rs +++ b/src/components/mail/compose.rs @@ -477,6 +477,9 @@ impl Composer { hostname.truncate_at_boundary(10); format!("{} [smtp: {}]", acc.name(), hostname) } + crate::conf::composing::SendMail::ServerSubmission => { + format!("{} [server submission]", acc.name()) + } }; (addr, desc) diff --git a/src/conf/accounts.rs b/src/conf/accounts.rs index be2a75ac..a90d7355 100644 --- a/src/conf/accounts.rs +++ b/src/conf/accounts.rs @@ -1326,6 +1326,25 @@ impl Account { } Ok(Some(handle)) } + SendMail::ServerSubmission => { + if self.backend_capabilities.supports_submission { + let job = self.backend.write().unwrap().submit( + message.clone().into_bytes(), + None, + None, + )?; + + let handle = if self.backend_capabilities.is_async { + self.job_executor.spawn_specialized(job) + } else { + self.job_executor.spawn_blocking(job) + }; + self.insert_job(handle.job_id, JobRequest::SendMessageBackground { handle }); + return Ok(None); + } + return Err(MeliError::new("Server does not support submission.") + .set_summary("Message not sent.")); + } } } @@ -1333,6 +1352,8 @@ impl Account { &self, send_mail: crate::conf::composing::SendMail, ) -> impl FnOnce(Arc) -> Pin> + Send>> + Send { + let capabilities = self.backend_capabilities.clone(); + let backend = self.backend.clone(); |message: Arc| -> Pin> + Send>> { Box::pin(async move { use crate::conf::composing::SendMail; @@ -1386,6 +1407,19 @@ impl Account { .mail_transaction(message.as_str(), None) .await } + SendMail::ServerSubmission => { + if capabilities.supports_submission { + let fut = backend.write().unwrap().submit( + message.as_bytes().to_vec(), + None, + None, + )?; + fut.await?; + return Ok(()); + } + return Err(MeliError::new("Server does not support submission.") + .set_summary("Message not sent.")); + } } }) } diff --git a/src/conf/composing.rs b/src/conf/composing.rs index b871b47a..970075fc 100644 --- a/src/conf/composing.rs +++ b/src/conf/composing.rs @@ -90,10 +90,50 @@ impl Default for ComposingSettings { } } +macro_rules! named_unit_variant { + ($variant:ident) => { + pub mod $variant { + pub fn serialize(serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(stringify!($variant)) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result<(), D::Error> + where + D: serde::Deserializer<'de>, + { + struct V; + impl<'de> serde::de::Visitor<'de> for V { + type Value = (); + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str(concat!("\"", stringify!($variant), "\"")) + } + fn visit_str(self, value: &str) -> Result { + if value == stringify!($variant) { + Ok(()) + } else { + Err(E::invalid_value(serde::de::Unexpected::Str(value), &self)) + } + } + } + deserializer.deserialize_str(V) + } + } + }; +} + +mod strings { + named_unit_variant!(server_submission); +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(untagged)] pub enum SendMail { #[cfg(feature = "smtp")] Smtp(melib::smtp::SmtpServerConf), + #[serde(with = "strings::server_submission")] + ServerSubmission, ShellCommand(String), }