From 4b27ae2b91e7ad8cdc002099e7b67c373e18c5dc Mon Sep 17 00:00:00 2001 From: Manos Pitsidianakis Date: Mon, 13 Jul 2020 19:00:13 +0300 Subject: [PATCH] melib: Add experimental SMTP client --- Cargo.lock | 7 +- melib/Cargo.toml | 4 +- melib/src/connections.rs | 6 +- melib/src/email/address.rs | 8 + melib/src/lib.rs | 3 + melib/src/smtp.rs | 919 +++++++++++++++++++++++++++++++++++++ 6 files changed, 941 insertions(+), 6 deletions(-) create mode 100644 melib/src/smtp.rs diff --git a/Cargo.lock b/Cargo.lock index c4be75e7..e63dab8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -68,9 +68,9 @@ checksum = "b41b7ea54a0c9d92199de89e20e58d49f02f8e699814ef3fdf266f6f748d15c7" [[package]] name = "base64" -version = "0.12.1" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d1ccbaf7d9ec9537465a97bf19edc1a4e158ecb49fc16178202238c569cc42" +checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff" [[package]] name = "bincode" @@ -930,6 +930,7 @@ name = "melib" version = "0.5.0" dependencies = [ "async-stream", + "base64 0.12.3", "bincode", "bitflags", "crossbeam", @@ -1448,7 +1449,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b82c9238b305f26f53443e3a4bc8528d64b8d0bee408ec949eb7bf5635ec680" dependencies = [ - "base64 0.12.1", + "base64 0.12.3", "bytes", "encoding_rs", "futures-core", diff --git a/melib/Cargo.toml b/melib/Cargo.toml index 48e6b20b..2c96f1af 100644 --- a/melib/Cargo.toml +++ b/melib/Cargo.toml @@ -45,9 +45,10 @@ libloading = "0.6.2" futures = "0.3.5" smol = "0.1.18" async-stream = "0.2.1" +base64 = { version = "0.12.3", optional = true } [features] -default = ["unicode_algorithms", "imap_backend", "maildir_backend", "mbox_backend", "vcard", "sqlite3"] +default = ["unicode_algorithms", "imap_backend", "maildir_backend", "mbox_backend", "vcard", "sqlite3", "smtp"] debug-tracing = [] unicode_algorithms = ["unicode-segmentation"] @@ -58,3 +59,4 @@ notmuch_backend = [] jmap_backend = ["reqwest", "serde_json" ] vcard = [] sqlite3 = ["rusqlite", ] +smtp = ["native-tls", "base64"] diff --git a/melib/src/connections.rs b/melib/src/connections.rs index ffbc88a4..31047035 100644 --- a/melib/src/connections.rs +++ b/melib/src/connections.rs @@ -150,6 +150,8 @@ pub fn lookup_ipv4(host: &str, port: u16) -> crate::Result } } - Err(crate::error::MeliError::new("Cannot lookup address") - .set_kind(crate::error::ErrorKind::Network)) + Err( + crate::error::MeliError::new(format!("Could not lookup address {}:{}", host, port)) + .set_kind(crate::error::ErrorKind::Network), + ) } diff --git a/melib/src/email/address.rs b/melib/src/email/address.rs index 05f488cc..53c47754 100644 --- a/melib/src/email/address.rs +++ b/melib/src/email/address.rs @@ -83,6 +83,14 @@ impl Address { Address::Group(_) => String::new(), } } + + pub fn address_spec_raw(&self) -> &[u8] { + match self { + Address::Mailbox(m) => m.address_spec.display_bytes(&m.raw), + Address::Group(g) => &g.raw, + } + } + pub fn get_fqdn(&self) -> Option { match self { Address::Mailbox(m) => { diff --git a/melib/src/lib.rs b/melib/src/lib.rs index aef77c5b..fd3b3fd4 100644 --- a/melib/src/lib.rs +++ b/melib/src/lib.rs @@ -117,6 +117,8 @@ pub mod connections; pub mod parsec; pub mod search; +#[cfg(feature = "smtp")] +pub mod smtp; #[cfg(feature = "sqlite3")] pub mod sqlite3; @@ -132,6 +134,7 @@ extern crate bitflags; extern crate uuid; pub use smallvec; +pub use futures; pub use smol; pub use crate::backends::{Backends, RefreshEvent, RefreshEventConsumer, SpecialUsageMailbox}; diff --git a/melib/src/smtp.rs b/melib/src/smtp.rs new file mode 100644 index 00000000..4a83bd79 --- /dev/null +++ b/melib/src/smtp.rs @@ -0,0 +1,919 @@ +/* + * meli - smtp + * + * Copyright 2020 Manos 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 . + */ + +#![allow(clippy::just_underscores_and_digits)] +#![allow(clippy::needless_lifetimes)] + +/*! + * SMTP client support + * + * The connection and methods are `async` and uses the `smol` runtime. + *# Example + * + *```not_run + *extern crate melib; + * + *use melib::futures; + *use melib::smol; + *use melib::smtp::*; + *use melib::Result; + *let conf = SmtpServerConf { + * hostname: "smtp.mail.gr".into(), + * port: 587, + * security: SmtpSecurity::StartTLS { + * danger_accept_invalid_certs: false, + * }, + * extensions: SmtpExtensionSupport::default(), + * auth: SmtpAuth::Auto { + * username: "l15".into(), + * password: Password::CommandEval( + * "gpg2 --no-tty -q -d ~/.passwords/mail.gpg".into(), + * ), + * require_auth: true, + * }, + *}; + *std::thread::spawn(|| smol::run(futures::future::pending::<()>())); + * + *let mut conn = futures::executor::block_on(SmtpConnection::new_connection(conf)).unwrap(); + *futures::executor::block_on(conn.mail_transaction(r#"To: l10@mail.gr + *Subject: Fwd: SMTP TEST + *From: Me + *Message-Id: + *Date: Mon, 13 Jul 2020 09:02:15 +0300 + * + *Prescriptions-R-X"#, + * b"l15@mail.gr", + * b"l10@mail.gr", + *)).unwrap(); + *Ok(()) + *``` + */ + +use crate::connections::{lookup_ipv4, Connection}; +use crate::email::{parser::BytesExt, Envelope}; +use crate::error::{MeliError, Result, ResultIntoMeliError}; +use futures::io::{AsyncReadExt, AsyncWriteExt}; +use native_tls::TlsConnector; +use smallvec::SmallVec; +use smol::blocking; +use smol::Async as AsyncWrapper; +use std::borrow::Cow; +use std::convert::TryFrom; +use std::net::TcpStream; +use std::process::Command; + +/// Kind of server security (StartTLS/TLS/None) the client should attempt +#[derive(Debug, Copy, PartialEq, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum SmtpSecurity { + #[serde(alias = "starttls", alias = "STARTTLS")] + StartTLS { + #[serde(default = "false_val")] + danger_accept_invalid_certs: bool, + }, + #[serde(alias = "auto")] + Auto { + #[serde(default = "false_val")] + danger_accept_invalid_certs: bool, + }, + #[serde(alias = "tls", alias = "TLS")] + Tls { + #[serde(default = "false_val")] + danger_accept_invalid_certs: bool, + }, + #[serde(alias = "none")] + None, +} + +impl Default for SmtpSecurity { + fn default() -> Self { + Self::Auto { + danger_accept_invalid_certs: false, + } + } +} + +/// Source of user's password for SMTP authentication +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "value")] +pub enum Password { + #[serde(alias = "raw")] + Raw(String), + #[serde(alias = "command_evaluation", alias = "command_eval")] + CommandEval(String), +} + +/// Kind of server authentication the client should attempt +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum SmtpAuth { + #[serde(alias = "none")] + None, + #[serde(alias = "auto")] + Auto { + username: String, + password: Password, + #[serde(default = "true_val")] + require_auth: bool, + }, + // md5, sasl, etc +} + +fn true_val() -> bool { + true +} + +fn false_val() -> bool { + false +} + +impl SmtpAuth { + fn require_auth(&self) -> bool { + use SmtpAuth::*; + match self { + None => false, + Auto { require_auth, .. } => *require_auth, + } + } +} + +/// Server configuration for connecting the SMTP client +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SmtpServerConf { + pub hostname: String, + pub port: u16, + #[serde(default)] + pub envelope_from: String, + pub auth: SmtpAuth, + #[serde(default)] + pub security: SmtpSecurity, + #[serde(default)] + pub extensions: SmtpExtensionSupport, +} + +//example: "SIZE 52428800", "8BITMIME", "PIPELINING", "CHUNKING", "PRDR", +/// Configured SMTP extensions to use +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SmtpExtensionSupport { + pipelining: bool, + chunking: bool, + //Essentially, the PRDR extension to SMTP allows (but does not require) an SMTP server to + //issue multiple responses after a message has been transferred, by mutual consent of the + //client and server. SMTP clients that support the PRDR extension then use the expanded + //responses as supplemental data to the responses that were received during the earlier + //envelope exchange. + prdr: bool, + binarymime: bool, + //Resources: + //- http://www.postfix.org/SMTPUTF8_README.html + smtputf8: bool, + auth: bool, + dsn_notify: Option>, +} + +impl Default for SmtpExtensionSupport { + fn default() -> Self { + Self { + pipelining: true, + chunking: true, + prdr: true, + binarymime: false, + smtputf8: true, + auth: true, + dsn_notify: Some("FAILURE".into()), + } + } +} + +#[derive(Debug)] +/// SMTP client session object. +/// +/// See module-wide documentation. +pub struct SmtpConnection { + stream: AsyncWrapper, + read_buffer: String, + server_conf: SmtpServerConf, +} + +impl SmtpConnection { + /// Performs connection and if configured: TLS negotiation and SMTP AUTH + pub async fn new_connection(mut server_conf: SmtpServerConf) -> Result { + let path = &server_conf.hostname; + let mut res = String::with_capacity(8 * 1024); + let stream = match server_conf.security { + SmtpSecurity::Auto { + danger_accept_invalid_certs, + } + | SmtpSecurity::Tls { + danger_accept_invalid_certs, + } + | SmtpSecurity::StartTLS { + danger_accept_invalid_certs, + } => { + let mut connector = TlsConnector::builder(); + if danger_accept_invalid_certs { + connector.danger_accept_invalid_certs(true); + } + let connector = connector + .build() + .chain_err_kind(crate::error::ErrorKind::Network)?; + + let addr = lookup_ipv4(path, server_conf.port)?; + let mut socket = AsyncWrapper::new(Connection::Tcp( + TcpStream::connect_timeout(&addr, std::time::Duration::new(4, 0)) + .chain_err_kind(crate::error::ErrorKind::Network)?, + )) + .chain_err_kind(crate::error::ErrorKind::Network)?; + let pre_ehlo_extensions_reply = read_lines( + &mut socket, + &mut res, + Some((ReplyCode::_220, &[])), + &mut String::new(), + ) + .await?; + drop(pre_ehlo_extensions_reply); + //debug!(pre_ehlo_extensions_reply); + if let SmtpSecurity::Auto { .. } = server_conf.security { + if server_conf.port == 465 { + server_conf.security = SmtpSecurity::Tls { + danger_accept_invalid_certs, + }; + } else if server_conf.port == 587 { + server_conf.security = SmtpSecurity::StartTLS { + danger_accept_invalid_certs, + }; + } else { + return Err(MeliError::new("Please specify what SMTP security transport to use explicitly instead of `auto`.")); + } + } + socket + .write_all(b"EHLO meli.delivery\r\n") + .await + .chain_err_kind(crate::error::ErrorKind::Network)?; + if let SmtpSecurity::StartTLS { .. } = server_conf.security { + let pre_tls_extensions_reply = read_lines( + &mut socket, + &mut res, + Some((ReplyCode::_250, &[])), + &mut String::new(), + ) + .await?; + drop(pre_tls_extensions_reply); + //debug!(pre_tls_extensions_reply); + socket + .write_all(b"STARTTLS\r\n") + .await + .chain_err_kind(crate::error::ErrorKind::Network)?; + let _post_starttls_extensions_reply = read_lines( + &mut socket, + &mut res, + Some((ReplyCode::_220, &[])), + &mut String::new(), + ) + .await?; + //debug!(post_starttls_extensions_reply); + } + + let mut ret = { + let socket = socket + .into_inner() + .chain_err_kind(crate::error::ErrorKind::Network)?; + let _path = path.clone(); + + socket.set_nonblocking(false)?; + let conn_result = blocking!(connector.connect(&_path, socket)); + /* + if let Err(native_tls::HandshakeError::WouldBlock(midhandshake_stream)) = + conn_result + { + let mut midhandshake_stream = Some(midhandshake_stream); + loop { + match midhandshake_stream.take().unwrap().handshake() { + Ok(r) => { + conn_result = Ok(r); + break; + } + Err(native_tls::HandshakeError::WouldBlock(stream)) => { + midhandshake_stream = Some(stream); + } + p => { + p.chain_err_kind(crate::error::ErrorKind::Network)?; + } + } + } + } + */ + AsyncWrapper::new(Connection::Tls( + conn_result.chain_err_kind(crate::error::ErrorKind::Network)?, + )) + .chain_err_kind(crate::error::ErrorKind::Network)? + }; + ret.write_all(b"EHLO meli.delivery\r\n") + .await + .chain_err_kind(crate::error::ErrorKind::Network)?; + ret + } + SmtpSecurity::None => { + let addr = lookup_ipv4(path, server_conf.port)?; + let mut ret = AsyncWrapper::new(Connection::Tcp( + TcpStream::connect_timeout(&addr, std::time::Duration::new(4, 0)) + .chain_err_kind(crate::error::ErrorKind::Network)?, + )) + .chain_err_kind(crate::error::ErrorKind::Network)?; + res.clear(); + let reply = read_lines( + &mut ret, + &mut res, + Some((ReplyCode::_220, &[])), + &mut String::new(), + ) + .await?; + let code = reply.code; + let result: Result = reply.into(); + result?; + if code != ReplyCode::_220 { + return Err(MeliError::new(format!( + "SMTP Server didn't reply with a 220 greeting: {:?}", + Reply::new(&res, code) + ))); + } + ret.write_all(b"EHLO meli.delivery\r\n") + .await + .chain_err_kind(crate::error::ErrorKind::Network)?; + ret + } + }; + let mut ret = SmtpConnection { + stream, + read_buffer: String::new(), + server_conf: server_conf.clone(), + }; + let no_auth_needed: bool; + { + let pre_auth_extensions_reply = ret + .read_lines(&mut res, Some((ReplyCode::_250, &[]))) + .await?; + if ret.server_conf.auth != SmtpAuth::None + && ret.server_conf.auth.require_auth() + && !pre_auth_extensions_reply + .lines + .iter() + .any(|l| l.starts_with("AUTH")) + { + return Err(MeliError::new(format!( + "SMTP Server doesn't advertise Authentication support. Server response was: {:?}", + pre_auth_extensions_reply + ))); + } + no_auth_needed = + ret.server_conf.auth == SmtpAuth::None || !ret.server_conf.auth.require_auth(); + if no_auth_needed { + ret.set_extension_support(pre_auth_extensions_reply); + } + } + if !no_auth_needed { + match &ret.server_conf.auth { + SmtpAuth::None => {} + SmtpAuth::Auto { + username, password, .. + } => { + // # RFC 4616 The PLAIN SASL Mechanism + // # https://www.ietf.org/rfc/rfc4616.txt + // message = [authzid] UTF8NUL authcid UTF8NUL passwd + // authcid = 1*SAFE ; MUST accept up to 255 octets + // authzid = 1*SAFE ; MUST accept up to 255 octets + // passwd = 1*SAFE ; MUST accept up to 255 octets + // UTF8NUL = %x00 ; UTF-8 encoded NUL character + let username_password = match password { + Password::Raw(p) => base64::encode(format!("\0{}\0{}", username, p)), + Password::CommandEval(command) => { + let _command = command.clone(); + + let mut output = blocking!(Command::new("sh") + .args(&["-c", &_command]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output())?; + if !output.status.success() { + return Err(MeliError::new(format!( + "SMTP password evaluation command `{}` returned {}: {}", + command, + output.status, + String::from_utf8_lossy(&output.stderr) + ))); + } + let mut buf = + Vec::with_capacity(2 + username.len() + output.stdout.len()); + buf.push(b'\0'); + buf.extend(username.as_bytes().to_vec()); + buf.push(b'\0'); + if output.stdout.ends_with(b"\n") { + output.stdout.pop(); + } + buf.extend(output.stdout); + base64::encode(buf) + } + }; + let mut auth_command: SmallVec<[&[u8]; 16]> = SmallVec::new(); + auth_command.push(b"AUTH PLAIN "); + auth_command.push(username_password.as_bytes()); + ret.send_command(&auth_command).await?; + ret.read_lines(&mut res, Some((ReplyCode::_235, &[]))) + .await + .chain_err_kind(crate::error::ErrorKind::Authentication)?; + ret.send_command(&[b"EHLO meli.delivery"]).await?; + } + } + { + let extensions_reply = ret + .read_lines(&mut res, Some((ReplyCode::_250, &[]))) + .await?; + ret.set_extension_support(extensions_reply); + } + } + //debug!(&res); + Ok(ret) + } + + fn set_extension_support(&mut self, reply: Reply<'_>) { + debug_assert_eq!(reply.code, ReplyCode::_250); + self.server_conf.extensions.pipelining &= reply.lines.contains(&"PIPELINING"); + self.server_conf.extensions.chunking &= reply.lines.contains(&"CHUNKING"); + self.server_conf.extensions.prdr &= reply.lines.contains(&"PRDR"); + self.server_conf.extensions.binarymime &= reply.lines.contains(&"BINARYMIME"); + self.server_conf.extensions.smtputf8 &= reply.lines.contains(&"SMTPUTF8"); + if !reply.lines.contains(&"DSN") { + self.server_conf.extensions.dsn_notify = None; + } + } + + pub async fn read_lines<'r>( + &mut self, + ret: &'r mut String, + expected_reply_code: Option<(ReplyCode, &[ReplyCode])>, + ) -> Result> { + read_lines( + &mut self.stream, + ret, + expected_reply_code, + &mut self.read_buffer, + ) + .await + } + + pub async fn send_command(&mut self, command: &[&[u8]]) -> Result<()> { + //debug!( + // "sending command: {}", + // command + // .iter() + // .fold(String::new(), |mut acc, b| { + // acc.push_str(unsafe { std::str::from_utf8_unchecked(b) }); + // acc + // }) + // .trim() + //); + for c in command { + self.stream + .write_all(c) + .await + .chain_err_kind(crate::error::ErrorKind::Network)?; + } + self.stream + .write_all(b"\r\n") + .await + .chain_err_kind(crate::error::ErrorKind::Network) + } + + /// Sends mail + pub async fn mail_transaction(&mut self, mail: &str) -> Result<()> { + let mut res = String::with_capacity(8 * 1024); + let mut pipelining_queue: SmallVec<[ExpectedReplyCode; 16]> = SmallVec::new(); + let mut pipelining_results: SmallVec<[Result; 16]> = SmallVec::new(); + let mut prdr_results: SmallVec<[Result; 16]> = SmallVec::new(); + let dsn_notify = self.server_conf.extensions.dsn_notify.clone(); + let envelope_from = self.server_conf.envelope_from.clone(); + let envelope = Envelope::from_bytes(mail.as_bytes(), None) + .chain_err_summary(|| "SMTP submission was aborted")?; + if envelope.to().is_empty() { + return Err(MeliError::new("SMTP submission was aborted because there was no e-mail address found in the To: header field. Consider adding recipients.")); + } + let mut current_command: SmallVec<[&[u8]; 16]> = SmallVec::new(); + //first step in the procedure is the MAIL command. + // MAIL FROM: [SP ] + current_command.push(b"MAIL FROM:<"); + if !envelope_from.is_empty() { + current_command.push(envelope_from.trim().as_bytes()); + } else { + if envelope.from().is_empty() { + return Err(MeliError::new("SMTP submission was aborted because there was no e-mail address found in the From: header field. Consider adding a valid value or setting `envelope_from` in SMTP client settings")); + } else if envelope.from().len() != 1 { + return Err(MeliError::new("SMTP submission was aborted because there was more than one e-mail address found in the From: header field. Consider setting `envelope_from` in SMTP client settings")); + } + current_command.push(envelope.from()[0].address_spec_raw().trim()); + } + current_command.push(b">"); + if self.server_conf.extensions.prdr { + current_command.push(b" PRDR"); + } + self.send_command(¤t_command).await?; + current_command.clear(); + if !self.server_conf.extensions.pipelining { + self.read_lines(&mut res, Some((ReplyCode::_250, &[]))) + .await?; + } else { + pipelining_queue.push(Some((ReplyCode::_250, &[]))); + } + //The second step in the procedure is the RCPT command. This step of the procedure can + //be repeated any number of times. If accepted, the SMTP server returns a "250 OK" + //reply. If the mailbox specification is not acceptable for some reason, the server MUST + //return a reply indicating whether the failure is permanent (i.e., will occur again if + //the client tries to send the same address again) or temporary (i.e., the address might + //be accepted if the client tries again later). + for addr in envelope.to() { + current_command.push(b"RCPT TO:<"); + current_command.push(addr.address_spec_raw().trim()); + if let Some(dsn_notify) = dsn_notify.as_ref() { + current_command.push(b" NOTIFY="); + current_command.push(dsn_notify.as_bytes()); + } else { + current_command.push(b">"); + } + self.send_command(¤t_command).await?; + + //RCPT TO: [ SP ] + //If accepted, the SMTP server returns a "250 OK" reply and stores the forward-path. + if !self.server_conf.extensions.pipelining { + self.read_lines(&mut res, Some((ReplyCode::_250, &[]))) + .await?; + } else { + pipelining_queue.push(Some((ReplyCode::_250, &[]))); + } + } + + //Since it has been a common source of errors, it is worth noting that spaces are not + //permitted on either side of the colon following FROM in the MAIL command or TO in the + //RCPT command. The syntax is exactly as given above. + + //The third step in the procedure is the DATA command + //(or some alternative specified in a service extension). + //DATA + self.send_command(&[b"DATA"]).await?; + //Client SMTP implementations that employ pipelining MUST check ALL statuses associated + //with each command in a group. For example, if none of the RCPT TO recipient addresses + //were accepted the client must then check the response to the DATA command -- the client + //cannot assume that the DATA command will be rejected just because none of the RCPT TO + //commands worked. If the DATA command was properly rejected the client SMTP can just + //issue RSET, but if the DATA command was accepted the client SMTP should send a single + //dot. + let mut _all_error = self.server_conf.extensions.pipelining; + let mut _any_error = false; + let mut ignore_mailfrom = true; + for expected_reply_code in pipelining_queue { + let reply = self.read_lines(&mut res, expected_reply_code).await?; + if !ignore_mailfrom { + _all_error &= reply.code.is_err(); + _any_error |= reply.code.is_err(); + } + ignore_mailfrom = false; + pipelining_results.push(reply.into()); + } + + //If accepted, the SMTP server returns a 354 Intermediate reply and considers all + //succeeding lines up to but not including the end of mail data indicator to be the + //message text. When the end of text is successfully received and stored, the + //SMTP-receiver sends a "250 OK" reply. + self.read_lines(&mut res, Some((ReplyCode::_354, &[]))) + .await?; + + //Before sending a line of mail text, the SMTP client checks the first character of the + //line.If it is a period, one additional period is inserted at the beginning of the line. + for line in mail.lines() { + if line.starts_with('.') { + self.stream + .write_all(b".") + .await + .chain_err_kind(crate::error::ErrorKind::Network)?; + } + self.stream + .write_all(line.as_bytes()) + .await + .chain_err_kind(crate::error::ErrorKind::Network)?; + self.stream + .write_all(b"\r\n") + .await + .chain_err_kind(crate::error::ErrorKind::Network)?; + } + + if !mail.ends_with('\n') { + self.stream + .write_all(b".\r\n") + .await + .chain_err_kind(crate::error::ErrorKind::Network)?; + } + + //The mail data are terminated by a line containing only a period, that is, the character + //sequence ".", where the first is actually the terminator of the + //previous line (see Section 4.5.2). This is the end of mail data indication. + self.stream + .write_all(b".\r\n") + .await + .chain_err_kind(crate::error::ErrorKind::Network)?; + + //The end of mail data indicator also confirms the mail transaction and tells the SMTP + //server to now process the stored recipients and mail data. If accepted, the SMTP + //server returns a "250 OK" reply. + let reply_code = self + .read_lines( + &mut res, + Some(( + ReplyCode::_250, + if self.server_conf.extensions.prdr { + &[ReplyCode::_353] + } else { + &[] + }, + )), + ) + .await? + .code; + // PRDR extension only: + if reply_code == ReplyCode::_353 { + // Read one line for each accepted recipient. + for pipe_result in pipelining_results.iter().skip(1) { + if pipe_result.is_err() { + continue; + } + prdr_results.push(self.read_lines(&mut res, None).await?.into()); + } + } + Ok(()) + } +} + +/// Expected reply code in a single or multi-line reply by the server +pub type ExpectedReplyCode = Option<(ReplyCode, &'static [ReplyCode])>; + +/// Recognized kinds of SMTP reply codes +#[derive(Debug, Copy, Clone, PartialEq)] +pub enum ReplyCode { + ///System status, or system help reply + _211, + ///Help message (Information on how to use the receiver or the meaning of a particular non-standard command; this reply is useful only to the human user) + _214, + /// Service ready + _220, + /// Service closing transmission channel + _221, + ///Authentication successful, + _235, + ///Requested mail action okay, completed + _250, + ///User not local; will forward to (See Section 3.4) + _251, + ///Cannot VRFY user, but will accept message and attempt delivery (See Section 3.5.3) + _252, + ///PRDR specific, eg "content analysis has started| + _353, + ///Start mail input; end with . + _354, + /// Service not available, closing transmission channel (This may be a reply to any command if the service knows it must shut down) + _421, + ///Requested mail action not taken: mailbox unavailable (e.g., mailbox busy or temporarily blocked for policy reasons) + _450, + ///Requested action aborted: local error in processing + _451, + ///Requested action not taken: insufficient system storage + _452, + ///Server unable to accommodate parameters + _455, + ///Syntax error, command unrecognized (This may include errors such as command line too long) + _500, + ///Syntax error in parameters or arguments + _501, + ///Command not implemented (see Section 4.2.4) + _502, + ///Bad sequence of commands + _503, + ///Command parameter not implemented + _504, + ///Authentication failed + _535, + ///Requested action not taken: mailbox unavailable (e.g., mailbox not found, no access, or command rejected for policy reasons) + _550, + ///User not local; please try (See Section 3.4) + _551, + ///Requested mail action aborted: exceeded storage allocation + _552, + ///Requested action not taken: mailbox name not allowed (e.g., mailbox syntax incorrect) + _553, + ///Transaction failed (Or, in the case of a connection-opening response, "No SMTP service here") + _554, + ///MAIL FROM/RCPT TO parameters not recognized or not implemented + _555, + ///Must issue a STARTTLS command first + _530, +} + +impl ReplyCode { + fn as_str(&self) -> &'static str { + use ReplyCode::*; + match self { + _211 => "System status, or system help reply", + _214 => "Help message", + _220 => "Service ready", + _221 => "Service closing transmission channel", + _250 => "Requested mail action okay, completed", + _235 => "Authentication successful", + _251 => "User not local; will forward", + _252 => "Cannot VRFY user, but will accept message and attempt delivery", + _353 => "PRDR specific notice", + _354 => "Start mail input; end with .", + _421 => "Service not available, closing transmission channel", + _450 => "Requested mail action not taken: mailbox unavailable", + _451 => "Requested action aborted: local error in processing", + _452 => "Requested action not taken: insufficient system storage", + _455 => "Server unable to accommodate parameters", + _500 => "Syntax error, command unrecognized", + _501 => "Syntax error in parameters or arguments", + _502 => "Command not implemented", + _503 => "Bad sequence of commands", + _504 => "Command parameter not implemented", + _535 => "Authentication failed", + _550 => "Requested action not taken: mailbox unavailable (e.g., mailbox not found, no access, or command rejected for policy reasons)", + _551 => "User not local", + _552 => "Requested mail action aborted: exceeded storage allocation", + _553 => "Requested action not taken: mailbox name not allowed (e.g., mailbox syntax incorrect)", + _554 => "Transaction failed", + _555 => "MAIL FROM/RCPT TO parameters not recognized or not implemented", + _530 => "Must issue a STARTTLS command first", + } + } + + fn is_err(&self) -> bool { + use ReplyCode::*; + match self { + _421 | _450 | _451 | _452 | _455 | _500 | _501 | _502 | _503 | _504 | _535 | _550 + | _551 | _552 | _553 | _554 | _555 | _530 => true, + _ => false, + } + } +} + +impl TryFrom<&'_ str> for ReplyCode { + type Error = MeliError; + fn try_from(val: &'_ str) -> Result { + if val.len() != 3 { + debug!("{}", val); + } + debug_assert!(val.len() == 3); + use ReplyCode::*; + match val { + "211" => Ok(_211), + "214" => Ok(_214), + "220" => Ok(_220), + "221" => Ok(_221), + "235" => Ok(_235), + "250" => Ok(_250), + "251" => Ok(_251), + "252" => Ok(_252), + "354" => Ok(_354), + "421" => Ok(_421), + "450" => Ok(_450), + "451" => Ok(_451), + "452" => Ok(_452), + "455" => Ok(_455), + "500" => Ok(_500), + "501" => Ok(_501), + "502" => Ok(_502), + "503" => Ok(_503), + "504" => Ok(_504), + "535" => Ok(_535), + "550" => Ok(_550), + "551" => Ok(_551), + "552" => Ok(_552), + "553" => Ok(_553), + "554" => Ok(_554), + "555" => Ok(_555), + _ => Err(MeliError::new(format!("Unknown SMTP reply code: {}", val))), + } + } +} + +/// A single line or multi-line server reply, along with its reply code +#[derive(Debug, Clone)] +pub struct Reply<'s> { + pub code: ReplyCode, + pub lines: SmallVec<[&'s str; 16]>, +} + +impl<'s> Into> for Reply<'s> { + fn into(self: Reply<'s>) -> Result { + if self.code.is_err() { + Err(MeliError::new(self.lines.join("\n")).set_summary(self.code.as_str())) + } else { + Ok(self.code) + } + } +} + +impl<'s> Reply<'s> { + /// `s` must be raw SMTP output i.e each line must start with 3 digit reply code, a space + /// or '-' and end with '\r\n' + pub fn new(s: &'s str, code: ReplyCode) -> Self { + let lines: SmallVec<_> = s.lines().map(|l| &l[4..l.len()]).collect(); + Reply { lines, code } + } +} + +async fn read_lines<'r>( + _self: &mut (impl futures::io::AsyncRead + std::marker::Unpin), + ret: &'r mut String, + expected_reply_code: Option<(ReplyCode, &[ReplyCode])>, + buffer: &mut String, +) -> Result> { + let mut buf: [u8; 1024] = [0; 1024]; + ret.clear(); + ret.extend(buffer.drain(..)); + let mut last_line_idx: usize = 0; + let mut returned_code: Option = None; + 'read_loop: loop { + while let Some(pos) = ret[last_line_idx..].find("\r\n") { + // "Formally, a reply is defined to be the sequence: a three-digit code, , one line of text, and , or a multiline reply (as defined in the same section)." + if ret[last_line_idx..].len() < 4 + || !ret[last_line_idx..] + .chars() + .take(3) + .all(|c| c.is_ascii_digit()) + { + return Err(MeliError::new(format!("Invalid SMTP reply: {}", ret))); + } + if let Some(ref returned_code) = returned_code { + if ReplyCode::try_from(ret[last_line_idx..].get(..3).unwrap())? != *returned_code { + buffer.extend(ret.drain(last_line_idx..)); + if ret.lines().last().unwrap().chars().nth(4).unwrap() != ' ' { + return Err(MeliError::new(format!("Invalid SMTP reply: {}", ret))); + } + break 'read_loop; + } + if ret[last_line_idx + 3..].starts_with(' ') { + buffer.extend(ret.drain(last_line_idx + pos + "\r\n".len()..)); + break 'read_loop; + } + } else { + if ret[last_line_idx + 3..].starts_with(' ') { + buffer.extend(ret.drain(last_line_idx + pos + "\r\n".len()..)); + break 'read_loop; + } + returned_code = Some(ReplyCode::try_from(&ret[last_line_idx..3])?); + } + last_line_idx += pos + "\r\n".len(); + } + match _self.read(&mut buf).await { + Ok(0) => break, + Ok(b) => { + ret.push_str(unsafe { std::str::from_utf8_unchecked(&buf[0..b]) }); + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + panic!("block"); + } + Err(e) => { + return Err(MeliError::from(e).set_kind(crate::error::ErrorKind::Network)); + } + } + } + let code = ReplyCode::try_from(&ret[..3])?; + let reply = Reply::new(ret, code); + //debug!(&reply); + if expected_reply_code + .map(|(exp, exp_list)| exp != reply.code && !exp_list.contains(&reply.code)) + .unwrap_or(false) + { + let result: Result = reply.clone().into(); + result?; + return Err(MeliError::new(format!( + "SMTP Server didn't reply with expected greeting code {:?}: {:?}", + expected_reply_code.unwrap(), + reply + ))); + } + Ok(reply) +}