melib/nntp: implement NNTP posting

pull/144/head
Manos Pitsidianakis 2021-09-04 00:32:57 +03:00
parent 978939d8e3
commit 521f634e7b
Signed by: Manos Pitsidianakis
GPG Key ID: 73627C2F690DF710
11 changed files with 246 additions and 58 deletions

View File

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

View File

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

View File

@ -401,6 +401,15 @@ pub trait MailBackend: ::std::fmt::Debug + Send + Sync {
) -> ResultFuture<SmallVec<[EnvelopeHash; 512]>> {
Err(MeliError::new("Unimplemented."))
}
fn submit(
&self,
bytes: Vec<u8>,
mailbox_hash: Option<MailboxHash>,
flags: Option<Flag>,
) -> ResultFuture<()> {
Err(MeliError::new("Not supported in this backend."))
}
}
/// A `BackendOp` manages common operations for the various mail backends. They only live for the

View File

@ -588,7 +588,9 @@ impl ImapConnection {
pub fn connect<'a>(&'a mut self) -> Pin<Box<dyn Future<Output = Result<()>> + 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(())
}

View File

@ -131,6 +131,7 @@ impl MailBackend for NntpType {
)
})
.collect::<Vec<(String, MailBackendExtensionStatus)>>();
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<SmallVec<[EnvelopeHash; 512]>> {
Err(MeliError::new("Unimplemented."))
}
fn submit(
&self,
bytes: Vec<u8>,
mailbox_hash: Option<MailboxHash>,
flags: Option<Flag>,
) -> 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 {

View File

@ -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<Connection>,
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<String> = res.lines().skip(1).map(|l| l.to_string()).collect();
let capabilities: HashSet<String> =
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)

View File

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

View File

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

View File

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

View File

@ -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<String>) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> + Send {
let capabilities = self.backend_capabilities.clone();
let backend = self.backend.clone();
|message: Arc<String>| -> Pin<Box<dyn Future<Output = Result<()>> + 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."));
}
}
})
}

View File

@ -90,10 +90,50 @@ impl Default for ComposingSettings {
}
}
macro_rules! named_unit_variant {
($variant:ident) => {
pub mod $variant {
pub fn serialize<S>(serializer: S) -> Result<S::Ok, S::Error>
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<E: serde::de::Error>(self, value: &str) -> Result<Self::Value, E> {
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),
}