Author: Kevin Schoon [me@kevinschoon.com]
Hash: 76f184fc253adf8bedc9c79fecb67bb68f99f5f5
Timestamp: Tue, 30 Jul 2024 15:32:59 +0000 (2 months ago)

+199 -76 +/-4 browse
various cleanup, add a few useful macros
1diff --git a/maitred/src/lib.rs b/maitred/src/lib.rs
2index a642e9f..6da0b2e 100644
3--- a/maitred/src/lib.rs
4+++ b/maitred/src/lib.rs
5 @@ -3,5 +3,9 @@ mod server;
6 mod session;
7 mod transport;
8
9+ /// Low Level SMTP protocol is exported for convenience
10+ pub use smtp_proto;
11+
12 pub use error::Error;
13 pub use server::Server;
14+
15 diff --git a/maitred/src/server.rs b/maitred/src/server.rs
16index 72c47d0..84014ba 100644
17--- a/maitred/src/server.rs
18+++ b/maitred/src/server.rs
19 @@ -1,4 +1,4 @@
20- use std::time::{Duration, Instant};
21+ use std::time::Duration;
22
23 use futures::SinkExt;
24 use smtp_proto::Request;
25 @@ -15,6 +15,27 @@ const DEFAULT_GREETING: &str = "Maitred ESMTP Server";
26 // Maximum amount of time the server will wait for a command before closing
27 // the connection.
28 const DEFAULT_GLOBAL_TIMEOUT_SECS: u64 = 300;
29+ const DEFAULT_HELP_BANNER: &str = r#"
30+ Maitred ESMTP Server:
31+ see https://ayllu-forge.org/ayllu/maitred for more information.
32+ "#;
33+
34+ /// Maximum message size the server will accept
35+ const DEFAULT_MAXIMUM_SIZE: u64 = 5_000_000;
36+
37+ // target
38+ // 250-PIPELINING
39+ // 250-SIZE 10240000
40+ // 250-VRFY
41+ // 250-ETRN
42+ // 250-ENHANCEDSTATUSCODES
43+ // 250-8BITMIME
44+ // 250-DSN
45+ // 250-SMTPUTF8
46+ // 250 CHUNKING
47+
48+
49+ const DEFAULT_CAPABILITIES: u32 = smtp_proto::EXT_SIZE + smtp_proto::EXT_ENHANCED_STATUS_CODES;
50
51 #[derive(Clone)]
52 struct Configuration {
53 @@ -22,6 +43,20 @@ struct Configuration {
54 pub hostname: String,
55 pub greeting: String,
56 pub global_timeout: Duration,
57+ pub help_banner: String,
58+ pub maximum_size: u64,
59+ capabilities: u32,
60+ }
61+
62+ impl Configuration {
63+ pub fn session_opts(&self) -> SessionOptions {
64+ SessionOptions {
65+ hostname: self.hostname.clone(),
66+ capabilities: self.capabilities,
67+ help_banner: self.help_banner.clone(),
68+ maximum_size: self.maximum_size,
69+ }
70+ }
71 }
72
73 impl Default for Configuration {
74 @@ -31,6 +66,9 @@ impl Default for Configuration {
75 hostname: String::default(),
76 greeting: DEFAULT_GREETING.to_string(),
77 global_timeout: Duration::from_secs(DEFAULT_GLOBAL_TIMEOUT_SECS),
78+ help_banner: DEFAULT_HELP_BANNER.to_string(),
79+ capabilities: DEFAULT_CAPABILITIES,
80+ maximum_size: DEFAULT_MAXIMUM_SIZE,
81 }
82 }
83 }
84 @@ -70,6 +108,11 @@ impl Server {
85 self
86 }
87
88+ pub fn with_maximum_size(mut self, size: u64) -> Self {
89+ self.config.maximum_size = size;
90+ self
91+ }
92+
93 async fn process<T>(
94 &self,
95 mut framed: Framed<T, Transport>,
96 @@ -143,15 +186,7 @@ impl Server {
97 let addr = socket.local_addr()?;
98 tracing::info!("Accepted connection on: {:?}", addr);
99 let framed = Framed::new(socket, Transport::default());
100- if let Err(err) = self
101- .process(
102- framed,
103- SessionOptions {
104- hostname: self.config.hostname.clone(),
105- },
106- )
107- .await
108- {
109+ if let Err(err) = self.process(framed, self.config.session_opts()).await {
110 tracing::warn!("Client encountered an error: {:?}", err);
111 }
112 }
113 @@ -237,6 +272,7 @@ mod test {
114 framed,
115 crate::session::Options {
116 hostname: "localhost".to_string(),
117+ ..Default::default()
118 },
119 )
120 .await
121 diff --git a/maitred/src/session.rs b/maitred/src/session.rs
122index 80d5578..31d05dd 100644
123--- a/maitred/src/session.rs
124+++ b/maitred/src/session.rs
125 @@ -1,11 +1,47 @@
126 use std::result::Result as StdResult;
127
128+ use crate::transport::Response;
129 use bytes::Bytes;
130 use mail_parser::MessageParser;
131 use melib::Address;
132- use smtp_proto::{Request, Response};
133+ use smtp_proto::{EhloResponse, Request, Response as SmtpResponse};
134 use url::Host;
135
136+ /// Generate an SMTP response
137+ macro_rules! smtp_response {
138+ ($code:expr, $e1:expr, $e2:expr, $e3:expr, $name:expr) => {
139+ Response::General(SmtpResponse::new($code, $e1, $e2, $e3, $name.to_string()))
140+ };
141+ }
142+
143+ /// Generate a successful SMTP response
144+ macro_rules! smtp_ok {
145+ ($code:expr, $e1:expr, $e2:expr, $e3:expr, $name:expr) => {
146+ Ok::<Response<String>, Response<String>>(Response::General(SmtpResponse::new(
147+ $code,
148+ $e1,
149+ $e2,
150+ $e3,
151+ $name.to_string(),
152+ )))
153+ };
154+ }
155+
156+ /// Generate an SMTP response error
157+ macro_rules! smtp_err {
158+ ($code:expr, $e1:expr, $e2:expr, $e3:expr, $name:expr) => {
159+ Err::<Response<String>, Response<String>>(Response::General(SmtpResponse::new(
160+ $code,
161+ $e1,
162+ $e2,
163+ $e3,
164+ $name.to_string(),
165+ )))
166+ };
167+ }
168+
169+ /// Result generated as part of an SMTP session, an Err indicates a session
170+ /// level error that will be returned to the client.
171 pub type Result = StdResult<Response<String>, Response<String>>;
172
173 enum DataTransfer {
174 @@ -16,17 +52,23 @@ enum DataTransfer {
175 /// A greeting must be sent at the start of an SMTP connection when it is
176 /// first initialized.
177 pub fn greeting(hostname: &str, greeting: &str) -> Response<String> {
178- Response::new(220, 2, 0, 0, format!("{} {}", hostname, greeting))
179+ smtp_response!(220, 2, 0, 0, format!("{} {}", hostname, greeting))
180 }
181
182 /// Sent when the connection exceeds the maximum configured timeout
183 pub fn timeout(message: &str) -> Response<String> {
184- Response::new(421, 4, 4, 2, format!("Timeout exceeded: {}", message))
185+ smtp_response!(421, 4, 4, 2, format!("Timeout exceeded: {}", message))
186 }
187
188 /// Runtime options that influence server behavior
189+ #[derive(Default)]
190 pub(crate) struct Options {
191 pub hostname: String,
192+ /// Generic banner to show when the help command is sent without any
193+ /// arguments.
194+ pub help_banner: String,
195+ pub capabilities: u32,
196+ pub maximum_size: u64,
197 }
198
199 /// Stateful connection that coresponds to a single SMTP session
200 @@ -52,6 +94,7 @@ impl Session {
201 self.mail_to = None;
202 self.rcpt_to = None;
203 self.hostname = None;
204+ self.data_transfer = None;
205 }
206
207 /// Statefully process the SMTP command with optional data payload, any
208 @@ -71,42 +114,44 @@ impl Session {
209 self.history.push(req.clone());
210 match req {
211 Request::Ehlo { host } => {
212- self.hostname = Some(
213- Host::parse(host).map_err(|e| Response::new(500, 0, 0, 0, e.to_string()))?,
214- );
215- Ok(Response::new(250, 0, 0, 0, format!("Hello {}", host)))
216+ self.hostname = Some(Host::parse(host).map_err(|e| {
217+ Response::General(SmtpResponse::new(500, 0, 0, 0, e.to_string()))
218+ })?);
219+ let mut resp = EhloResponse::new(format!("Hello {}", host));
220+ resp.capabilities = opts.capabilities;
221+ resp.size = opts.maximum_size as usize;
222+ Ok(Response::Ehlo(resp))
223 }
224 Request::Lhlo { host } => {
225- self.hostname = Some(
226- Host::parse(host).map_err(|e| Response::new(500, 0, 0, 0, e.to_string()))?,
227- );
228- Ok(Response::new(250, 0, 0, 0, format!("Hello {}", host)))
229+ self.hostname =
230+ Some(Host::parse(host).map_err(|e| smtp_response!(500, 0, 0, 0, e))?);
231+ smtp_ok!(250, 0, 0, 0, format!("Hello {}", host))
232 }
233 Request::Helo { host } => {
234 self.hostname = Some(
235- Host::parse(host).map_err(|e| Response::new(500, 0, 0, 0, e.to_string()))?,
236+ Host::parse(host).map_err(|e| smtp_response!(500, 0, 0, 0, e.to_string()))?,
237 );
238- Ok(Response::new(250, 0, 0, 0, format!("Hello {}", host)))
239+ smtp_ok!(250, 0, 0, 0, format!("Hello {}", host))
240 }
241 Request::Mail { from } => {
242 let mail_to = Address::try_from(from.address.as_str()).map_err(|e| {
243- Response::new(
244+ smtp_response!(
245 500,
246 0,
247 0,
248 0,
249- format!("Cannot parse: {} {}", from.address, e),
250+ format!("cannot parse: {} {}", from.address, e)
251 )
252 })?;
253 self.mail_to = Some(mail_to.clone());
254- Ok(Response::new(250, 0, 0, 0, "OK".to_string()))
255+ smtp_ok!(250, 0, 0, 0, "OK")
256 }
257 Request::Rcpt { to } => {
258 let mail_to = Address::try_from(to.address.as_str()).map_err(|e| {
259- Response::new(500, 0, 0, 0, format!("Cannot parse: {} {}", to.address, e))
260+ smtp_response!(500, 0, 0, 0, format!("Cannot parse: {} {}", to.address, e))
261 })?;
262 self.mail_to = Some(mail_to.clone());
263- Ok(Response::new(250, 0, 0, 0, "OK".to_string()))
264+ smtp_ok!(250, 0, 0, 0, "OK")
265 }
266 Request::Bdat {
267 chunk_size: _,
268 @@ -122,14 +167,14 @@ impl Session {
269 data.expect("data returned without a payload").to_vec();
270 let parser = MessageParser::new();
271 let response = match parser.parse(&message_payload) {
272- Some(_) => Ok(Response::new(250, 0, 0, 0, "OK".to_string())),
273- None => Err(Response::new(
274+ Some(_) => smtp_ok!(250, 0, 0, 0, "OK"),
275+ None => smtp_err!(
276 500,
277 0,
278 0,
279 0,
280- "Cannot parse message payload".to_string(),
281- )),
282+ "Cannot parse message payload".to_string()
283+ ),
284 }?;
285 self.data_transfer = None;
286 self.body = Some(message_payload.clone());
287 @@ -139,23 +184,29 @@ impl Session {
288 } else {
289 tracing::info!("Initializing data transfer mode");
290 self.data_transfer = Some(DataTransfer::Bdat);
291- Ok(Response::new(
292- 354,
293- 0,
294- 0,
295- 0,
296- "Starting BDAT data transfer".to_string(),
297- ))
298+ smtp_ok!(354, 0, 0, 0, "Starting BDAT data transfer".to_string())
299 }
300 }
301 Request::Auth {
302 mechanism,
303 initial_response,
304 } => todo!(),
305- Request::Noop { value } => todo!(),
306+ Request::Noop { value: _ } => smtp_ok!(250, 0, 0, 0, "OK".to_string(),),
307 Request::Vrfy { value } => todo!(),
308 Request::Expn { value } => todo!(),
309- Request::Help { value } => todo!(),
310+ Request::Help { value } => {
311+ if value.is_empty() {
312+ smtp_ok!(250, 0, 0, 0, opts.help_banner.to_string())
313+ } else {
314+ smtp_ok!(
315+ 250,
316+ 0,
317+ 0,
318+ 0,
319+ format!("Help for {} is not currently available", value)
320+ )
321+ }
322+ }
323 Request::Etrn { name } => todo!(),
324 Request::Atrn { domains } => todo!(),
325 Request::Burl { uri, is_last } => todo!(),
326 @@ -171,14 +222,14 @@ impl Session {
327 data.expect("data returned without a payload").to_vec();
328 let parser = MessageParser::new();
329 let response = match parser.parse(&message_payload) {
330- Some(_) => Ok(Response::new(250, 0, 0, 0, "OK".to_string())),
331- None => Err(Response::new(
332+ Some(_) => smtp_ok!(250, 0, 0, 0, "OK".to_string()),
333+ None => smtp_err!(
334 500,
335 0,
336 0,
337 0,
338- "Cannot parse message payload".to_string(),
339- )),
340+ "Cannot parse message payload".to_string()
341+ ),
342 }?;
343 self.data_transfer = None;
344 self.body = Some(message_payload.clone());
345 @@ -188,20 +239,20 @@ impl Session {
346 } else {
347 tracing::info!("Initializing data transfer mode");
348 self.data_transfer = Some(DataTransfer::Data);
349- Ok(Response::new(
350+ smtp_ok!(
351 354,
352 0,
353 0,
354 0,
355- "Reading data input, end the message with <CRLF>.<CRLF>".to_string(),
356- ))
357+ "Reading data input, end the message with <CRLF>.<CRLF>".to_string()
358+ )
359 }
360 }
361 Request::Rset => {
362 self.reset();
363- Ok(Response::new(200, 0, 0, 0, "".to_string()))
364+ smtp_ok!(200, 0, 0, 0, "".to_string())
365 }
366- Request::Quit => Ok(Response::new(221, 0, 0, 0, "Ciao!".to_string())),
367+ Request::Quit => smtp_ok!(221, 0, 0, 0, "Ciao!".to_string()),
368 }
369 }
370 }
371 @@ -228,7 +279,6 @@ mod test {
372 println!("Response: {:?}", response);
373 match response {
374 Ok(actual_response) => {
375- assert!(actual_response.code < 400);
376 match &command.expected {
377 Ok(expected_response) => {
378 if !actual_response.eq(expected_response) {
379 @@ -255,7 +305,6 @@ mod test {
380 );
381 },
382 Err(expected_err) => {
383- assert!(actual_err.code > 300);
384 if !actual_err.eq(expected_err) {
385 panic!("Expected error does not match:\n\nActual: {:?}\n Expected: {:?}", actual_err, expected_err);
386 }
387 @@ -274,18 +323,12 @@ mod test {
388 host: EXAMPLE_HOSTNAME.to_string(),
389 },
390 payload: None,
391- expected: Ok(Response::new(
392- 250,
393- 0,
394- 0,
395- 0,
396- String::from("Hello example.org"),
397- )),
398+ expected: smtp_ok!(250, 0, 0, 0, String::from("Hello example.org")),
399 },
400 TestCase {
401 request: Request::Quit {},
402 payload: None,
403- expected: Ok(Response::new(221, 0, 0, 0, String::from("Ciao!"))),
404+ expected: smtp_ok!(221, 0, 0, 0, String::from("Ciao!")),
405 },
406 ];
407 let mut session = Session::default();
408 @@ -293,6 +336,7 @@ mod test {
409 &mut session,
410 &Options {
411 hostname: EXAMPLE_HOSTNAME.to_string(),
412+ ..Default::default()
413 },
414 requests,
415 );
416 @@ -308,13 +352,7 @@ mod test {
417 host: EXAMPLE_HOSTNAME.to_string(),
418 },
419 payload: None,
420- expected: Ok(Response::new(
421- 250,
422- 0,
423- 0,
424- 0,
425- String::from("Hello example.org"),
426- )),
427+ expected: smtp_ok!(250, 0, 0, 0, "Hello example.org"),
428 },
429 TestCase {
430 request: Request::Mail {
431 @@ -324,7 +362,7 @@ mod test {
432 },
433 },
434 payload: None,
435- expected: Ok(Response::new(250, 0, 0, 0, String::from("OK"))),
436+ expected: smtp_ok!(250, 0, 0, 0, "OK"),
437 },
438 TestCase {
439 request: Request::Rcpt {
440 @@ -334,19 +372,19 @@ mod test {
441 },
442 },
443 payload: None,
444- expected: Ok(Response::new(250, 0, 0, 0, String::from("OK"))),
445+ expected: smtp_ok!(250, 0, 0, 0, "OK"),
446 },
447 // initiate data transfer
448 TestCase {
449 request: Request::Data {},
450 payload: None,
451- expected: Ok(Response::new(
452+ expected: smtp_ok!(
453 354,
454 0,
455 0,
456 0,
457- String::from("Reading data input, end the message with <CRLF>.<CRLF>"),
458- )),
459+ "Reading data input, end the message with <CRLF>.<CRLF>"
460+ ),
461 },
462 // send the actual payload
463 TestCase {
464 @@ -360,7 +398,7 @@ Note that it doesn't end with a "." since that parsing happens as part of the
465 transport rather than the session.
466 "#,
467 )),
468- expected: Ok(Response::new(250, 0, 0, 0, String::from("OK"))),
469+ expected: smtp_ok!(250, 0, 0, 0, "OK"),
470 },
471 ];
472 let mut session = Session::default();
473 @@ -368,6 +406,7 @@ transport rather than the session.
474 &mut session,
475 &Options {
476 hostname: EXAMPLE_HOSTNAME.to_string(),
477+ ..Default::default()
478 },
479 requests,
480 );
481 diff --git a/maitred/src/transport.rs b/maitred/src/transport.rs
482index fa59793..b69ac39 100644
483--- a/maitred/src/transport.rs
484+++ b/maitred/src/transport.rs
485 @@ -1,11 +1,48 @@
486- use std::sync::Arc;
487 use std::{fmt::Display, io::Write};
488
489 use bytes::{Bytes, BytesMut};
490 use smtp_proto::request::receiver::{BdatReceiver, DataReceiver, RequestReceiver};
491- pub use smtp_proto::{Request, Response};
492+ pub use smtp_proto::{EhloResponse, Request, Response as SmtpResponse};
493 use tokio_util::codec::{Decoder, Encoder};
494
495+ #[derive(Debug)]
496+ pub enum Response<T>
497+ where
498+ T: Display,
499+ {
500+ General(SmtpResponse<T>),
501+ Ehlo(EhloResponse<T>),
502+ }
503+
504+ impl<T> PartialEq for Response<T>
505+ where
506+ T: Display,
507+ {
508+ fn eq(&self, other: &Self) -> bool {
509+ match self {
510+ Response::General(req) => match other {
511+ Response::General(other) => req.to_string() == other.to_string(),
512+ Response::Ehlo(_) => false,
513+ },
514+ Response::Ehlo(req) => match other {
515+ Response::General(_) => false,
516+ Response::Ehlo(other) => {
517+ // FIXME
518+ req.capabilities == other.capabilities
519+ && req.hostname.to_string() == other.hostname.to_string()
520+ && req.deliver_by == other.deliver_by
521+ && req.size == other.size
522+ && req.auth_mechanisms == other.auth_mechanisms
523+ && req.future_release_datetime == req.future_release_datetime
524+ && req.future_release_interval == req.future_release_interval
525+ }
526+ },
527+ }
528+ }
529+ }
530+
531+ impl<T> Eq for Response<T> where T: Display {}
532+
533 struct Wrapper<'a>(&'a mut BytesMut);
534
535 impl Write for Wrapper<'_> {
536 @@ -46,7 +83,14 @@ impl Encoder<Response<String>> for Transport {
537 type Error = crate::Error;
538
539 fn encode(&mut self, item: Response<String>, dst: &mut BytesMut) -> Result<(), Self::Error> {
540- item.write(Wrapper(dst))?;
541+ match item {
542+ Response::General(item) => {
543+ item.write(Wrapper(dst))?;
544+ }
545+ Response::Ehlo(item) => {
546+ item.write(Wrapper(dst))?;
547+ }
548+ }
549 Ok(())
550 }
551 }