From 54160b8b16037deb0f8ce480c353edd7932da9b7 Mon Sep 17 00:00:00 2001 From: Alexis Mousset Date: Mon, 12 May 2014 19:10:09 +0200 Subject: [PATCH] Reorganize sources --- .gitignore | 3 +- examples/client.rs | 107 +++++++++ src/client.rs | 543 +++++++++++++++++++++++++++++++++++++++++++++ src/commands.rs | 300 +++++++++++++++++++++++++ src/common.rs | 101 +++++++++ src/lib.rs | 68 ++++++ 6 files changed, 1121 insertions(+), 1 deletion(-) create mode 100644 examples/client.rs create mode 100644 src/client.rs create mode 100644 src/commands.rs create mode 100644 src/common.rs create mode 100644 src/lib.rs diff --git a/.gitignore b/.gitignore index 573320f..db42833 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ -build/ +target/ +bin/ doc/ *~ *.sh diff --git a/examples/client.rs b/examples/client.rs new file mode 100644 index 0000000..a67d108 --- /dev/null +++ b/examples/client.rs @@ -0,0 +1,107 @@ +// Copyright 2014 Alexis Mousset. See the COPYRIGHT +// file at the top-level directory of this distribution. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +#![crate_id = "client"] + +extern crate smtp; +extern crate getopts; +use std::io::stdin; +use std::io::net::tcp::TcpStream; +use std::strbuf::StrBuf; +use std::io::net::ip::Port; +use std::os; +use smtp::client::SmtpClient; +use getopts::{optopt,optflag,getopts,OptGroup,usage}; + +fn sendmail(source_address: StrBuf, recipient_addresses: Vec, message: StrBuf, server: StrBuf, port: Option, my_hostname: Option) { + let mut email_client: SmtpClient = + SmtpClient::new( + server, + port, + my_hostname); + email_client.send_mail( + source_address, + recipient_addresses, + message + ); +} + +fn print_usage(description: &str, _opts: &[OptGroup]) { + println!("{}", usage(description, _opts)); +} + +fn main() { + let args = os::args(); + + let program = args.get(0).clone(); + let description = format!("Usage: {0} [options...] recipients\n\n\ + This program reads a message on standard input until it reaches EOF,\ + then tries to send it using the given paramters.\n\n\ + Example: {0} -r user@example.org user@example.com < message.txt", program); + + let opts = [ + optopt("r", "reverse-path", "set the sender address", "FROM_ADDRESS"), + optopt("p", "port", "set the port to use, default is 25", "PORT"), + optopt("s", "server", "set the server to use, default is localhost", "SERVER"), + optopt("m", "my-hostname", "set the hostname used by the client", "MY_HOSTNAME"), + optflag("h", "help", "print this help menu"), + optflag("v", "verbose", "display the transaction details"), + ]; + let matches = match getopts(args.tail(), opts) { + Ok(m) => { m } + Err(f) => { fail!(f.to_err_msg()) } + }; + if matches.opt_present("h") { + print_usage(description, opts); + return; + } + + let sender = match matches.opt_str("r") { + Some(sender) => StrBuf::from_str(sender), + None => { + println!("The sender option is required"); + print_usage(program, opts); + return; + } + }; + + let server = match matches.opt_str("s") { + Some(server) => StrBuf::from_str(server), + None => StrBuf::from_str("localhost") + }; + + let my_hostname = match matches.opt_str("m") { + Some(my_hostname) => Some(StrBuf::from_str(my_hostname)), + None => None + }; + + let port = match matches.opt_str("p") { + Some(port) => from_str::(port), + None => None + + }; + + let recipients_str: &str = if !matches.free.is_empty() { + (*matches.free.get(0)).clone() + } else { + print_usage(description, opts); + return; + }; + let mut recipients = Vec::new(); + for recipient in recipients_str.split(' ') { + recipients.push(StrBuf::from_str(recipient)) + } + + let mut message = StrBuf::new(); + for line in stdin().lines() { + message = message.append(line.unwrap().to_str()); + } + + sendmail(sender, recipients, message, server, port, my_hostname); +} diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..19b39bf --- /dev/null +++ b/src/client.rs @@ -0,0 +1,543 @@ +// Copyright 2014 Alexis Mousset. See the COPYRIGHT +// file at the top-level directory of this distribution. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! SMTP client + +use std::fmt; +use std::fmt::{Show, Formatter}; +use std::str::from_utf8; +use std::result::Result; +use std::strbuf::StrBuf; +use std::io::{IoResult, Reader, Writer}; +use std::io::net::ip::{SocketAddr, Port}; +use std::io::net::tcp::TcpStream; +use std::io::net::addrinfo::get_host_addresses; +use common::{CRLF, get_first_word, unquote_email_address}; +use commands; +use commands::{SMTP_PORT, SmtpCommand, EsmtpParameter, SmtpResponse}; + +/// Information about an SMTP server +#[deriving(Clone)] +struct SmtpServerInfo { + /// Server name + name: T, + /// ESMTP features supported by the server + esmtp_features: Option> +} + +impl Show for SmtpServerInfo{ + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + f.buf.write( + format!("{} with {}", + self.name, + match self.esmtp_features.clone() { + Some(features) => features.to_str(), + None => format!("no supported features") + } + ).as_bytes() + ) + } +} + +impl SmtpServerInfo { + /// Parses supported ESMTP features + /// + /// TODO: Improve parsing + fn parse_esmtp_response(message: T) -> Option> { + let mut esmtp_features = Vec::new(); + for line in message.as_slice().split_str(CRLF) { + match from_str::>(line) { + Some(SmtpResponse{code: 250, message: message}) => { + match from_str::(message.unwrap().into_owned()) { + Some(keyword) => esmtp_features.push(keyword), + None => () + } + }, + _ => () + } + } + match esmtp_features.len() { + 0 => None, + _ => Some(esmtp_features) + } + } + + /// Checks if the server supports an ESMTP feature + fn supports_feature(&self, keyword: EsmtpParameter) -> Result { + match self.esmtp_features.clone() { + Some(esmtp_features) => { + for feature in esmtp_features.iter() { + if keyword.same_keyword_as(*feature) { + return Ok(*feature); + } + } + Err({}) + }, + None => Err({}) + } + } +} + +/// Contains the state of the current transaction +#[deriving(Eq,Clone)] +enum SmtpClientState { + /// The server is unconnected + Unconnected, + /// The connection was successful and the banner was received + Connected, + /// An HELO or EHLO was successful + HeloSent, + /// A MAIL command was successful send + MailSent, + /// At least one RCPT command was sucessful + RcptSent, + /// A DATA command was successful + DataSent +} + +macro_rules! check_state_in( + ($expected_states:expr) => ( + if ! $expected_states.contains(&self.state) { + fail!("Bad sequence of commands."); + } + ); +) + +macro_rules! check_state_not_in( + ($expected_states:expr) => ( + if $expected_states.contains(&self.state) { + fail!("Bad sequence of commands."); + } + ); +) + +macro_rules! smtp_fail_if_err( + ($response:expr) => ( + match $response { + Err(response) => { + self.smtp_fail(response) + }, + Ok(_) => {} + } + ); +) + +/// Structure that implements the SMTP client +pub struct SmtpClient { + /// TCP stream between client and server + stream: Option, + /// Host we are connecting to + host: T, + /// Port we are connecting on + port: Port, + /// Our hostname for HELO/EHLO commands + my_hostname: T, + /// Information about the server + /// Value is None before HELO/EHLO + server_info: Option>, + /// Transaction state, permits to check order againt RFCs + state: SmtpClientState +} + +impl SmtpClient { + /// Creates a new SMTP client + pub fn new(host: StrBuf, port: Option, my_hostname: Option) -> SmtpClient { + SmtpClient{ + stream: None, + host: host, + port: port.unwrap_or(SMTP_PORT), + my_hostname: my_hostname.unwrap_or(StrBuf::from_str("localhost")), + server_info: None, + state: Unconnected + } + } +} + +impl SmtpClient { + /// Connects to the configured server + pub fn connect(&mut self) -> Result, SmtpResponse> { + if !self.stream.is_none() { + fail!("The connection is already established"); + } + let ip = match get_host_addresses(self.host.clone().into_owned()) { + Ok(ip_vector) => *ip_vector.get(0), // TODO : select a random ip + Err(..) => fail!("Cannot resolve {:s}", self.host) + }; + self.stream = match TcpStream::connect(SocketAddr{ip: ip, port: self.port}) { + Ok(stream) => Some(stream), + Err(..) => fail!("Cannot connect to {:s}:{:u}", self.host, self.port) + }; + + // Log the connection + info!("Connection established to {}[{}]:{}", self.my_hostname.clone(), ip, self.port); + + match self.get_reply() { + Some(response) => match response.with_code(vec!(220)) { + Ok(response) => { + self.state = Connected; + Ok(response) + }, + Err(response) => { + Err(response) + } + }, + None => fail!("No banner on {}", self.host) + } + } + + /// Sends an email + pub fn send_mail(&mut self, from_address: StrBuf, to_addresses: Vec, message: StrBuf) { + let my_hostname = self.my_hostname.clone(); + + // Connect + match self.connect() { + Ok(_) => {}, + Err(response) => fail!("Cannot connect to {:s}:{:u}. Server says: {}", + self.host, + self.port, response + ) + } + + // Extended Hello or Hello + match self.ehlo(my_hostname.clone()) { + Err(SmtpResponse{code: 550, message: _}) => { + smtp_fail_if_err!(self.helo(my_hostname.clone())) + }, + Err(response) => { + self.smtp_fail(response) + } + _ => {} + } + + debug!("Server {:s}", self.server_info.clone().unwrap().to_str()); + + // Checks message encoding according to the server's capability + // TODO : Add an encoding check. + if ! self.server_info.clone().unwrap().supports_feature(commands::EightBitMime).is_ok() { + if ! message.clone().into_owned().is_ascii() { + self.smtp_fail("Server does not accepts UTF-8 strings"); + } + } + + // Mail + smtp_fail_if_err!(self.mail(from_address.clone(), None)); + + // Log the mail command + info!("from=<{}>, size={}, nrcpt={}", from_address, 42, to_addresses.len()); + + // Recipient + // TODO Return rejected addresses + // TODO Manage the number of recipients + for to_address in to_addresses.iter() { + smtp_fail_if_err!(self.rcpt(to_address.clone(), None)); + } + + // Data + smtp_fail_if_err!(self.data()); + + // Message content + let sent = self.message(message); + + if sent.clone().is_err() { + self.smtp_fail(sent.clone().err().unwrap()) + } + + info!("to=<{}>, status=sent ({})", to_addresses.clone().connect(">, to=<"), sent.clone().ok().unwrap()); + + // Quit + smtp_fail_if_err!(self.quit()); + } +} + +impl SmtpClient { + /// Sends an SMTP command + // TODO : ensure this is an ASCII string + fn send_command(&mut self, command: SmtpCommand) -> SmtpResponse { + self.send_and_get_response(format!("{}", command)) + } + + /// Sends an email + fn send_message(&mut self, message: StrBuf) -> SmtpResponse { + self.send_and_get_response(format!("{}{:s}.", message, CRLF)) + } + + /// Sends a complete message or a command to the server and get the response + fn send_and_get_response(&mut self, string: &str) -> SmtpResponse { + match (&mut self.stream.clone().unwrap() as &mut Writer) + .write_str(format!("{:s}{:s}", string, CRLF)) { + Ok(..) => debug!("Wrote: {:s}", string), + Err(..) => fail!("Could not write to stream") + } + + match self.get_reply() { + Some(response) => {debug!("Read: {:s}", response.to_str()); response}, + None => fail!("No answer on {:s}", self.host) + } + } + + /// Gets the SMTP response + fn get_reply(&mut self) -> Option> { + let response = match self.read_to_str() { + Ok(string) => string, + Err(..) => fail!("No answer") + }; + + from_str::>(response) + } + + /// Closes the connection and fail with a given messgage + fn smtp_fail(&mut self, reason: T) { + if self.is_connected() { + match self.quit() { + Ok(..) => {}, + Err(response) => fail!("Failed: {}", response) + } + } + self.close(); + fail!("Failed: {}", reason); + } + + /// Checks if the server is connected + pub fn is_connected(&mut self) -> bool { + self.noop().is_ok() + } + + /// Closes the TCP stream + pub fn close(&mut self) { + drop(self.stream.clone().unwrap()); + } + + /// Send a HELO command + pub fn helo(&mut self, my_hostname: StrBuf) -> Result, SmtpResponse> { + check_state_in!(vec!(Connected)); + + match self.send_command(commands::Hello(my_hostname.clone())).with_code(vec!(250)) { + Ok(response) => { + self.server_info = Some( + SmtpServerInfo{ + name: StrBuf::from_str(get_first_word(response.message.clone().unwrap().into_owned())), + esmtp_features: None + } + ); + self.state = HeloSent; + Ok(response) + }, + Err(response) => Err(response) + } + } + + /// Sends a EHLO command + pub fn ehlo(&mut self, my_hostname: StrBuf) -> Result, SmtpResponse> { + check_state_not_in!(vec!(Unconnected)); + + match self.send_command(commands::ExtendedHello(my_hostname.clone())).with_code(vec!(250)) { + Ok(response) => { + self.server_info = Some( + SmtpServerInfo{ + name: StrBuf::from_str(get_first_word(response.message.clone().unwrap().to_owned())), + esmtp_features: SmtpServerInfo::parse_esmtp_response(response.message.clone().unwrap()) + } + ); + self.state = HeloSent; + Ok(response) + }, + Err(response) => Err(response) + } + } + + /// Sends a MAIL command + pub fn mail(&mut self, from_address: StrBuf, options: Option>) -> Result, SmtpResponse> { + check_state_in!(vec!(HeloSent)); + + match self.send_command(commands::Mail(StrBuf::from_str(unquote_email_address(from_address.to_owned())), options)).with_code(vec!(250)) { + Ok(response) => { + self.state = MailSent; + Ok(response) + }, + Err(response) => { + Err(response) + } + } + } + + /// Sends a RCPT command + pub fn rcpt(&mut self, to_address: StrBuf, options: Option>) -> Result, SmtpResponse> { + check_state_in!(vec!(MailSent, RcptSent)); + + match self.send_command(commands::Recipient(StrBuf::from_str(unquote_email_address(to_address.to_owned())), options)).with_code(vec!(250)) { + Ok(response) => { + self.state = RcptSent; + Ok(response) + }, + Err(response) => { + Err(response) + } + } + } + + /// Sends a DATA command + pub fn data(&mut self) -> Result, SmtpResponse> { + check_state_in!(vec!(RcptSent)); + + match self.send_command(commands::Data).with_code(vec!(354)) { + Ok(response) => { + self.state = DataSent; + Ok(response) + }, + Err(response) => { + Err(response) + } + } + } + + /// Sends the message content + pub fn message(&mut self, message_content: StrBuf) -> Result, SmtpResponse> { + check_state_in!(vec!(DataSent)); + + match self.send_message(message_content).with_code(vec!(250)) { + Ok(response) => { + self.state = HeloSent; + Ok(response) + }, + Err(response) => { + Err(response) + } + } + } + + /// Sends a QUIT command + pub fn quit(&mut self) -> Result, SmtpResponse> { + check_state_not_in!(vec!(Unconnected)); + match self.send_command(commands::Quit).with_code(vec!(221)) { + Ok(response) => { + self.close(); + Ok(response) + }, + Err(response) => { + Err(response) + } + } + } + + /// Sends a RSET command + pub fn rset(&mut self) -> Result, SmtpResponse> { + check_state_not_in!(vec!(Unconnected)); + match self.send_command(commands::Reset).with_code(vec!(250)) { + Ok(response) => { + if vec!(MailSent, RcptSent, DataSent).contains(&self.state) { + self.state = HeloSent; + } + Ok(response) + }, + Err(response) => { + Err(response) + } + } + } + + /// Sends a NOOP commands + pub fn noop(&mut self) -> Result, SmtpResponse> { + check_state_not_in!(vec!(Unconnected)); + self.send_command(commands::Noop).with_code(vec!(250)) + } + + /// Sends a VRFY command + pub fn vrfy(&mut self, to_address: StrBuf) -> Result, SmtpResponse> { + check_state_not_in!(vec!(Unconnected)); + self.send_command(commands::Verify(to_address)).with_code(vec!(250)) + } +} + +impl Reader for SmtpClient { + /// Reads a string from the client socket + fn read(&mut self, buf: &mut [u8]) -> IoResult { + self.stream.clone().unwrap().read(buf) + } + + /// Reads a string from the client socket + // TODO: Size of response ?. + fn read_to_str(&mut self) -> IoResult<~str> { + let mut buf = [0u8, ..1000]; + + let response = match self.read(buf) { + Ok(bytes_read) => from_utf8(buf.slice_to(bytes_read - 1)).unwrap(), + Err(..) => fail!("Read error") + }; + + return Ok(response.to_owned()); + } +} + +impl Writer for SmtpClient { + /// Sends a string on the client socket + fn write(&mut self, buf: &[u8]) -> IoResult<()> { + self.stream.clone().unwrap().write(buf) + } + + /// Sends a string on the client socket + fn write_str(&mut self, string: &str) -> IoResult<()> { + self.stream.clone().unwrap().write_str(string) + } +} + +#[cfg(test)] +mod test { + use super::SmtpServerInfo; + use commands; + + #[test] + fn test_smtp_server_info_fmt() { + assert_eq!(format!("{}", SmtpServerInfo{ + name: "name", + esmtp_features: Some(vec!(commands::EightBitMime)) + }), "name with [8BITMIME]".to_owned()); + assert_eq!(format!("{}", SmtpServerInfo{ + name: "name", + esmtp_features: Some(vec!(commands::EightBitMime, commands::Size(42))) + }), "name with [8BITMIME, SIZE=42]".to_owned()); + assert_eq!(format!("{}", SmtpServerInfo{ + name: "name", + esmtp_features: None + }), "name with no supported features".to_owned()); + } + + #[test] + fn test_smtp_server_info_parse_esmtp_response() { + assert_eq!(SmtpServerInfo::parse_esmtp_response("me\r\n250-8BITMIME\r\n250 SIZE 42"), + Some(vec!(commands::EightBitMime, commands::Size(42)))); + assert_eq!(SmtpServerInfo::parse_esmtp_response("me\r\n250-8BITMIME\r\n250 UNKNON 42"), + Some(vec!(commands::EightBitMime))); + assert_eq!(SmtpServerInfo::parse_esmtp_response("me\r\n250-9BITMIME\r\n250 SIZE a"), + None); + assert_eq!(SmtpServerInfo::parse_esmtp_response("me\r\n250-SIZE 42\r\n250 SIZE 43"), + Some(vec!(commands::Size(42), commands::Size(43)))); + assert_eq!(SmtpServerInfo::parse_esmtp_response(""), + None); + } + + #[test] + fn test_smtp_server_info_supports_feature() { + assert_eq!(SmtpServerInfo{ + name: "name", + esmtp_features: Some(vec!(commands::EightBitMime)) + }.supports_feature(commands::EightBitMime), Ok(commands::EightBitMime)); + assert_eq!(SmtpServerInfo{ + name: "name", + esmtp_features: Some(vec!(commands::Size(42), commands::EightBitMime)) + }.supports_feature(commands::EightBitMime), Ok(commands::EightBitMime)); + assert_eq!(SmtpServerInfo{ + name: "name", + esmtp_features: Some(vec!(commands::Size(42), commands::EightBitMime)) + }.supports_feature(commands::Size(0)), Ok(commands::Size(42))); + assert!(SmtpServerInfo{ + name: "name", + esmtp_features: Some(vec!(commands::EightBitMime)) + }.supports_feature(commands::Size(42)).is_err()); + } +} diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..d2c44df --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,300 @@ +// Copyright 2014 Alexis Mousset. See the COPYRIGHT +// file at the top-level directory of this distribution. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! SMTP commands and ESMTP features library + +use std::fmt::{Show, Formatter, Result}; +use std::result; +use std::io::net::ip::Port; +use std::from_str::FromStr; + +use common::remove_trailing_crlf; + +/// Default SMTP port +pub static SMTP_PORT: Port = 25; +//pub static SMTPS_PORT: Port = 465; +//pub static SUBMISSION_PORT: Port = 587; + +/// Supported SMTP commands +/// +/// We do not implement the following SMTP commands, as they were deprecated in RFC 5321 +/// and must not be used by clients: +/// SEND, SOML, SAML, TURN +#[deriving(Eq,Clone)] +pub enum SmtpCommand { + /// Extended Hello command + ExtendedHello(T), + /// Hello command + Hello(T), + /// Mail command, takes optionnal options + Mail(T, Option>), + /// Recipient command, takes optionnal options + Recipient(T, Option>), + /// Data command + Data, + /// Reset command + Reset, + /// Verify command + Verify(T), + /// Expand command + Expand(T), + /// Help command, takes optionnal options + Help(Option), + /// Noop command + Noop, + /// Quit command + Quit, + +} + +impl Show for SmtpCommand { + fn fmt(&self, f: &mut Formatter) -> Result { + f.buf.write(match *self { + ExtendedHello(ref my_hostname) => + format!("EHLO {}", my_hostname.clone()), + Hello(ref my_hostname) => + format!("HELO {}", my_hostname.clone()), + Mail(ref from_address, None) => + format!("MAIL FROM:<{}>", from_address.clone()), + Mail(ref from_address, Some(ref options)) => + format!("MAIL FROM:<{}> {}", from_address.clone(), options.connect(" ")), + Recipient(ref to_address, None) => + format!("RCPT TO:<{}>", to_address.clone()), + Recipient(ref to_address, Some(ref options)) => + format!("RCPT TO:<{}> {}", to_address.clone(), options.connect(" ")), + Data => "DATA".to_owned(), + Reset => "RSET".to_owned(), + Verify(ref address) => + format!("VRFY {}", address.clone()), + Expand(ref address) => + format!("EXPN {}", address.clone()), + Help(None) => "HELP".to_owned(), + Help(Some(ref argument)) => + format!("HELP {}", argument.clone()), + Noop => "NOOP".to_owned(), + Quit => "QUIT".to_owned(), + }.as_bytes()) + } +} + +/// Supported ESMTP keywords +#[deriving(Eq,Clone)] +pub enum EsmtpParameter { + /// 8BITMIME keyword + /// + /// RFC 6152 : https://tools.ietf.org/html/rfc6152 + EightBitMime, + /// SIZE keyword + /// + /// RFC 1427 : https://tools.ietf.org/html/rfc1427 + Size(uint) +} + +impl Show for EsmtpParameter { + fn fmt(&self, f: &mut Formatter) -> Result { + f.buf.write( + match self { + &EightBitMime => "8BITMIME".to_owned(), + &Size(ref size) => format!("SIZE={}", size) + }.as_bytes() + ) + } +} + +impl FromStr for EsmtpParameter { + fn from_str(s: &str) -> Option { + let splitted : Vec<&str> = s.splitn(' ', 1).collect(); + match splitted.len() { + 1 => match *splitted.get(0) { + "8BITMIME" => Some(EightBitMime), + _ => None + }, + 2 => match (*splitted.get(0), from_str::(*splitted.get(1))) { + ("SIZE", Some(size)) => Some(Size(size)), + _ => None + }, + _ => None + } + } +} + +impl EsmtpParameter { + /// Checks if the ESMTP keyword is the same + pub fn same_keyword_as(&self, other: EsmtpParameter) -> bool { + if *self == other { + return true; + } + match (*self, other) { + (Size(_), Size(_)) => true, + _ => false + } + } +} + +/// Contains an SMTP reply, with separed code and message +/// +/// We do accept messages containing only a code, to comply with RFC5321 +#[deriving(Clone, Eq)] +pub struct SmtpResponse { + /// Server response code + pub code: u16, + /// Server response string + pub message: Option +} + +impl Show for SmtpResponse { + fn fmt(&self, f: &mut Formatter) -> Result { + f.buf.write( + match self.clone().message { + Some(message) => format!("{} {}", self.code.to_str(), message), + None => self.code.to_str() + }.as_bytes() + ) + } +} + +// FromStr ? +impl FromStr for SmtpResponse { + fn from_str(s: &str) -> Option> { + // If the string is too short to be a response code + if s.len() < 3 { + None + // If we have only a code, with or without a trailing space + } else if s.len() == 3 || (s.len() == 4 && s.slice(3,4) == " ") { + match from_str::(s.slice_to(3)) { + Some(code) => Some(SmtpResponse{ + code: code, + message: None + }), + None => None + + } + // If we have a code and a message + } else { + match ( + from_str::(s.slice_to(3)), + vec!(" ", "-").contains(&s.slice(3,4)), + StrBuf::from_str(remove_trailing_crlf(s.slice_from(4).to_owned())) + ) { + (Some(code), true, message) => Some(SmtpResponse{ + code: code, + message: Some(message) + }), + _ => None + + } + } + } +} + +impl SmtpResponse { + /// Checks the presence of the response code in the array of expected codes. + pub fn with_code(&self, expected_codes: Vec) -> result::Result,SmtpResponse> { + let response = self.clone(); + if expected_codes.contains(&self.code) { + Ok(response) + } else { + Err(response) + } + } +} + +#[cfg(test)] +mod test { + use super::{SmtpCommand, EsmtpParameter, SmtpResponse}; + + #[test] + fn test_command_fmt() { + let noop: SmtpCommand = super::Noop; + assert_eq!(format!("{}", noop), "NOOP".to_owned()); + assert_eq!(format!("{}", super::ExtendedHello("me")), "EHLO me".to_owned()); + assert_eq!(format!("{}", + super::Mail("test", Some(vec!("option")))), "MAIL FROM: option".to_owned() + ); + } + + #[test] + fn test_esmtp_parameter_same_keyword_as() { + assert_eq!(super::EightBitMime.same_keyword_as(super::EightBitMime), true); + assert_eq!(super::Size(42).same_keyword_as(super::Size(42)), true); + assert_eq!(super::Size(42).same_keyword_as(super::Size(43)), true); + assert_eq!(super::Size(42).same_keyword_as(super::EightBitMime), false); + } + + #[test] + fn test_esmtp_parameter_fmt() { + assert_eq!(format!("{}", super::EightBitMime), "8BITMIME".to_owned()); + assert_eq!(format!("{}", super::Size(42)), "SIZE=42".to_owned()); + } + + #[test] + fn test_esmtp_parameter_from_str() { + assert_eq!(from_str::("8BITMIME"), Some(super::EightBitMime)); + assert_eq!(from_str::("SIZE 42"), Some(super::Size(42))); + assert_eq!(from_str::("SIZ 42"), None); + assert_eq!(from_str::("SIZE 4a2"), None); + // TODO: accept trailing spaces ? + assert_eq!(from_str::("SIZE 42 "), None); + } + + #[test] + fn test_smtp_response_fmt() { + assert_eq!(format!("{}", SmtpResponse{code: 200, message: Some("message")}), "200 message".to_owned()); + } + + #[test] + fn test_smtp_response_from_str() { + assert_eq!(from_str::>("200 response message"), + Some(SmtpResponse{ + code: 200, + message: Some(StrBuf::from_str("response message")) + }) + ); + assert_eq!(from_str::>("200-response message"), + Some(SmtpResponse{ + code: 200, + message: Some(StrBuf::from_str("response message")) + }) + ); + assert_eq!(from_str::>("200"), + Some(SmtpResponse{ + code: 200, + message: None + }) + ); + assert_eq!(from_str::>("200 "), + Some(SmtpResponse{ + code: 200, + message: None + }) + ); + assert_eq!(from_str::>("200-response\r\nmessage"), + Some(SmtpResponse{ + code: 200, + message: Some(StrBuf::from_str("response\r\nmessage")) + }) + ); + assert_eq!(from_str::>("2000response message"), None); + assert_eq!(from_str::>("20a response message"), None); + assert_eq!(from_str::>("20 "), None); + assert_eq!(from_str::>("20"), None); + assert_eq!(from_str::>("2"), None); + assert_eq!(from_str::>(""), None); + } + + #[test] + fn test_smtp_response_with_code() { + assert_eq!(SmtpResponse{code: 200, message: Some("message")}.with_code(vec!(200)), + Ok(SmtpResponse{code: 200, message: Some("message")})); + assert_eq!(SmtpResponse{code: 400, message: Some("message")}.with_code(vec!(200)), + Err(SmtpResponse{code: 400, message: Some("message")})); + assert_eq!(SmtpResponse{code: 200, message: Some("message")}.with_code(vec!(200, 300)), + Ok(SmtpResponse{code: 200, message: Some("message")})); + } +} diff --git a/src/common.rs b/src/common.rs new file mode 100644 index 0000000..cb1554f --- /dev/null +++ b/src/common.rs @@ -0,0 +1,101 @@ +// Copyright 2014 Alexis Mousset. See the COPYRIGHT +// file at the top-level directory of this distribution. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Common definitions for SMTP +//! +//! Needs to be organized later + +pub static SP: &'static str = " "; +pub static CRLF: &'static str = "\r\n"; + +/// Adds quotes to emails if needed +pub fn quote_email_address(address: ~str) -> ~str { + match address.len() { + 0..1 => format!("<{:s}>", address), + _ => match (address.slice_to(1), address.slice_from(address.len() - 1)) { + ("<", ">") => address, + _ => format!("<{:s}>", address) + } + } +} + +/// Removes quotes from emails if needed +pub fn unquote_email_address(address: ~str) -> ~str { + match address.len() { + 0..1 => address, + _ => match (address.slice_to(1), address.slice_from(address.len() - 1)) { + ("<", ">") => address.slice(1, address.len() - 1).to_owned(), + _ => address + } + } +} + +/// Removes the trailing line return at the end of a string +pub fn remove_trailing_crlf(string: ~str) -> ~str { + if string.len() > 1 && string.slice_from(string.len() - 2) == CRLF { + string.slice_to(string.len() - 2).to_owned() + } else if string.len() > 0 && string.slice_from(string.len() - 1) == "\r" { + string.slice_to(string.len() - 1).to_owned() + } else { + string + } +} + +/// Returns the first word of a string, or the string if it contains no space +pub fn get_first_word(string: ~str) -> ~str { + string.split_str(CRLF).next().unwrap().splitn(' ', 1).next().unwrap().to_owned() +} + +#[cfg(test)] +mod test { + #[test] + fn test_quote_email_address() { + assert_eq!(super::quote_email_address("address".to_owned()), "
".to_owned()); + assert_eq!(super::quote_email_address("
".to_owned()), "
".to_owned()); + assert_eq!(super::quote_email_address("a".to_owned()), "".to_owned()); + assert_eq!(super::quote_email_address("".to_owned()), "<>".to_owned()); + } + + #[test] + fn test_unquote_email_address() { + assert_eq!(super::unquote_email_address("
".to_owned()), "address".to_owned()); + assert_eq!(super::unquote_email_address("address".to_owned()), "address".to_owned()); + assert_eq!(super::unquote_email_address("".to_owned()), "".to_owned()); + assert_eq!(super::unquote_email_address("a".to_owned()), "a".to_owned()); + assert_eq!(super::unquote_email_address("".to_owned()), "".to_owned()); + } + + #[test] + fn test_remove_trailing_crlf() { + assert_eq!(super::remove_trailing_crlf("word".to_owned()), "word".to_owned()); + assert_eq!(super::remove_trailing_crlf("word\r\n".to_owned()), "word".to_owned()); + assert_eq!(super::remove_trailing_crlf("word\r\n ".to_owned()), "word\r\n ".to_owned()); + assert_eq!(super::remove_trailing_crlf("word\r".to_owned()), "word".to_owned()); + assert_eq!(super::remove_trailing_crlf("\r\n".to_owned()), "".to_owned()); + assert_eq!(super::remove_trailing_crlf("\r".to_owned()), "".to_owned()); + assert_eq!(super::remove_trailing_crlf("a".to_owned()), "a".to_owned()); + assert_eq!(super::remove_trailing_crlf("".to_owned()), "".to_owned()); + } + + #[test] + fn test_get_first_word() { + assert_eq!(super::get_first_word("first word".to_owned()), "first".to_owned()); + assert_eq!(super::get_first_word("first word\r\ntest".to_owned()), "first".to_owned()); + assert_eq!(super::get_first_word("first".to_owned()), "first".to_owned()); + assert_eq!(super::get_first_word("".to_owned()), "".to_owned()); + assert_eq!(super::get_first_word("\r\n".to_owned()), "".to_owned()); + assert_eq!(super::get_first_word("a\r\n".to_owned()), "a".to_owned()); + // Manage cases of empty line, spaces at the beginning, ... + //assert_eq!(super::get_first_word(" a".to_owned()), "a".to_owned()); + //assert_eq!(super::get_first_word("\r\n a".to_owned()), "a".to_owned()); + assert_eq!(super::get_first_word(" \r\n".to_owned()), "".to_owned()); + assert_eq!(super::get_first_word("\r\n ".to_owned()), "".to_owned()); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..11126ba --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,68 @@ +// Copyright 2014 Alexis Mousset. See the COPYRIGHT +// file at the top-level directory of this distribution. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! # Rust SMTP client +//! +//! The client does its best to follow RFC 5321 (https://tools.ietf.org/html/rfc5321). +//! +//! It also implements the following extensions : +//! +//! * 8BITMIME (RFC 6152 : https://tools.ietf.org/html/rfc6152) +//! * SIZE (RFC 1427 : https://tools.ietf.org/html/rfc1427) +//! +//! ## What this client is NOT made for +//! +//! Send emails to public email servers. It is not designed to smartly handle servers responses, +//! to rate-limit emails, to make retries, and all that complicated stuff needed to politely +//! talk to public servers. +//! +//! What this client does is basically try once to send the email, and say if it worked. +//! It should only be used to transfer emails to a relay server. +//! +//! ## Usage +//! +//! ```rust +//! extern crate smtp; +//! use std::io::net::tcp::TcpStream; +//! use smtp::client::SmtpClient; +//! use std::strbuf::StrBuf; +//! +//! let mut email_client: SmtpClient = +//! SmtpClient::new(StrBuf::from_str("localhost"), None, None); +//! email_client.send_mail( +//! StrBuf::from_str("user@example.com"), +//! vec!(StrBuf::from_str("user@example.org")), +//! StrBuf::from_str("Test email") +//! ); +//! ``` + +#![crate_id = "smtp#0.1-pre"] +#![crate_type = "rlib"] +#![crate_type = "dylib"] + +#![desc = "Rust SMTP client"] +#![comment = "Simple SMTP client"] +#![license = "MIT/ASL2"] +#![doc(html_root_url = "http://amousset.github.io/rust-smtp/smtp/")] + +#![feature(macro_rules)] +#![feature(phase)] +#![deny(non_camel_case_types)] +#![deny(missing_doc)] +#![deny(unnecessary_qualification)] +#![deny(non_uppercase_statics)] +#![deny(unnecessary_typecast)] +#![deny(unused_result)] +#![deny(deprecated_owned_vector)] + +#![feature(phase)] #[phase(syntax, link)] extern crate log; + +pub mod commands; +pub mod common; +pub mod client;