Skip to main content

netget/server/lmtp/
wire.rs

1//! LMTP (RFC 2033) framing shared by the server and the client: bounded CRLF lines, reply
2//! rendering and parsing, path extraction and the message summary handed to handlers.
3//!
4//! Every limit here is enforced on the length the peer *sends*, before anything is buffered
5//! for it: a line longer than [`MAX_LINE_BYTES`] is refused without being kept, and a message
6//! body past the configured size is drained and discarded rather than stored.
7use anyhow::{bail, Context, Result};
8use serde_json::{json, Map, Value};
9use std::time::Duration;
10use tokio::io::{AsyncBufRead, AsyncBufReadExt};
11
12/// RFC 5321 4.5.3.1.6: a text line is at most 1000 octets including CRLF.
13pub const MAX_LINE_BYTES: usize = 1000;
14/// RFC 5321 4.5.3.1.8 requires a server to accept at least 100 recipients.
15pub const MAX_RECIPIENTS: usize = 100;
16/// Default message size limit, advertised through the SIZE extension.
17pub const DEFAULT_MAX_MESSAGE_BYTES: u64 = 10 * 1024 * 1024;
18/// Upper bound on the configurable message size limit.
19pub const MAX_MESSAGE_BYTES_LIMIT: u64 = 64 * 1024 * 1024;
20/// Body text handed to a handler; the rest is reported as truncated, never invented.
21pub const MAX_BODY_FOR_HANDLER: usize = 64 * 1024;
22/// Header fields handed to a handler.
23pub const MAX_HEADERS: usize = 100;
24/// Lines in one multi-line reply the client accepts.
25pub const MAX_REPLY_LINES: usize = 64;
26/// Connect, write and reply deadline.
27pub const IO_TIMEOUT: Duration = Duration::from_secs(30);
28
29/// Outcome of reading one bounded line.
30pub enum Line {
31    Text(String),
32    TooLong,
33    Eof,
34}
35
36/// Read one CRLF (or bare LF) terminated line, refusing past `MAX_LINE_BYTES` without
37/// buffering the excess. A refused line is consumed through its terminator.
38pub async fn read_line<R: AsyncBufRead + Unpin>(
39    reader: &mut R,
40    deadline: Duration,
41) -> Result<Line> {
42    tokio::time::timeout(deadline, read_line_inner(reader))
43        .await
44        .context("LMTP read deadline")?
45}
46
47async fn read_line_inner<R: AsyncBufRead + Unpin>(reader: &mut R) -> Result<Line> {
48    let mut line = Vec::new();
49    let mut overflow = false;
50    loop {
51        let available = reader.fill_buf().await?;
52        if available.is_empty() {
53            if line.is_empty() && !overflow {
54                return Ok(Line::Eof);
55            }
56            bail!("LMTP peer closed mid-line");
57        }
58        let (chunk, found) = match available.iter().position(|b| *b == b'\n') {
59            Some(i) => (&available[..=i], true),
60            None => (available, false),
61        };
62        let taken = chunk.len();
63        if !overflow {
64            if line.len() + taken > MAX_LINE_BYTES {
65                overflow = true;
66                line.clear();
67            } else {
68                line.extend_from_slice(chunk);
69            }
70        }
71        reader.consume(taken);
72        if found {
73            if overflow {
74                return Ok(Line::TooLong);
75            }
76            while matches!(line.last(), Some(b'\n' | b'\r')) {
77                line.pop();
78            }
79            return Ok(Line::Text(String::from_utf8_lossy(&line).into_owned()));
80        }
81    }
82}
83
84/// One reply line or a multi-line reply: `code`, an enhanced status code and text lines.
85pub fn render(code: u16, enhanced: &str, text: &str) -> String {
86    let text = sanitize(text);
87    if enhanced.is_empty() {
88        format!("{code} {text}\r\n")
89    } else {
90        format!("{code} {enhanced} {text}\r\n")
91    }
92}
93
94pub fn render_multi(code: u16, lines: &[String]) -> String {
95    let mut out = String::new();
96    for (i, line) in lines.iter().enumerate() {
97        let sep = if i + 1 == lines.len() { ' ' } else { '-' };
98        out.push_str(&format!("{code}{sep}{}\r\n", sanitize(line)));
99    }
100    out
101}
102
103/// Strip control characters so handler text cannot inject a second reply line.
104pub fn sanitize(text: &str) -> String {
105    let cleaned = crate::utils::sanitize::line_field(text);
106    let trimmed = cleaned.trim();
107    let mut out = String::new();
108    for c in trimmed.chars() {
109        if out.len() + c.len_utf8() > 400 {
110            break;
111        }
112        out.push(c);
113    }
114    out
115}
116
117/// A parsed reply: the code, and the text of each line without the code.
118#[derive(Debug, Clone)]
119pub struct Reply {
120    pub code: u16,
121    pub lines: Vec<String>,
122}
123
124impl Reply {
125    pub fn text(&self) -> String {
126        self.lines.join(" ")
127    }
128    pub fn positive(&self) -> bool {
129        (200..400).contains(&self.code)
130    }
131    pub fn to_json(&self) -> Value {
132        json!({"code": self.code, "text": self.text()})
133    }
134}
135
136/// Read one (possibly multi-line) reply. Codes must agree across lines.
137pub async fn read_reply<R: AsyncBufRead + Unpin>(reader: &mut R) -> Result<Reply> {
138    let mut lines = Vec::new();
139    let mut code = None;
140    loop {
141        let line = match read_line(reader, IO_TIMEOUT).await? {
142            Line::Text(line) => line,
143            Line::TooLong => bail!("LMTP reply line exceeds {MAX_LINE_BYTES} bytes"),
144            Line::Eof => bail!("LMTP server closed the connection before replying"),
145        };
146        let bytes = line.as_bytes();
147        if bytes.len() < 3 || !bytes[..3].iter().all(u8::is_ascii_digit) {
148            bail!("Malformed LMTP reply line");
149        }
150        let this: u16 = line[..3].parse()?;
151        if let Some(previous) = code {
152            if previous != this {
153                bail!("LMTP multi-line reply changed code from {previous} to {this}");
154            }
155        }
156        code = Some(this);
157        let more = bytes.get(3) == Some(&b'-');
158        if bytes.len() > 3 && !matches!(bytes[3], b' ' | b'-') {
159            bail!("Malformed LMTP reply separator");
160        }
161        lines.push(line.get(4..).unwrap_or("").to_string());
162        if lines.len() > MAX_REPLY_LINES {
163            bail!("LMTP reply exceeds {MAX_REPLY_LINES} lines");
164        }
165        if !more {
166            return Ok(Reply { code: this, lines });
167        }
168    }
169}
170
171/// Extract the path from `FROM:<a@b> SIZE=1` / `TO:<a@b>`; returns the address and parameters.
172pub fn parse_path(argument: &str, keyword: &str) -> Option<(String, Vec<String>)> {
173    let argument = argument.trim_start();
174    if argument.len() < keyword.len() || !argument[..keyword.len()].eq_ignore_ascii_case(keyword) {
175        return None;
176    }
177    let rest = argument[keyword.len()..].trim_start();
178    let rest = rest.strip_prefix('<')?;
179    let end = rest.find('>')?;
180    let address = rest[..end].trim().to_string();
181    if address.len() > 256
182        || crate::utils::sanitize::has_controls(&address)
183        || address.contains(' ')
184    {
185        return None;
186    }
187    let params = rest[end + 1..]
188        .split_whitespace()
189        .map(str::to_string)
190        .collect();
191    Some((address, params))
192}
193
194/// A mailbox the client may put in MAIL FROM / RCPT TO: printable, no spaces or brackets.
195pub fn valid_mailbox(address: &str, allow_empty: bool) -> bool {
196    (allow_empty || !address.is_empty())
197        && address.len() <= 256
198        && !crate::utils::sanitize::has_controls(address)
199        && !address.contains([' ', '<', '>'])
200}
201
202/// Summary of a received message for a handler: headers as fields, the body as text.
203pub fn summarize(raw: &[u8]) -> (Map<String, Value>, String, bool) {
204    let text = String::from_utf8_lossy(raw);
205    let (head, body) = match text.find("\r\n\r\n") {
206        Some(i) => (&text[..i], &text[i + 4..]),
207        None => match text.find("\n\n") {
208            Some(i) => (&text[..i], &text[i + 2..]),
209            None => (text.as_ref(), ""),
210        },
211    };
212    let mut headers = Map::new();
213    let mut current: Option<(String, String)> = None;
214    let flush = |current: &mut Option<(String, String)>, headers: &mut Map<String, Value>| {
215        if let Some((name, value)) = current.take() {
216            if headers.len() < MAX_HEADERS && !headers.contains_key(&name) {
217                headers.insert(name, Value::String(value.trim().to_string()));
218            }
219        }
220    };
221    for line in head.lines() {
222        if line.starts_with(' ') || line.starts_with('\t') {
223            if let Some((_, value)) = current.as_mut() {
224                value.push(' ');
225                value.push_str(line.trim());
226            }
227            continue;
228        }
229        flush(&mut current, &mut headers);
230        if let Some((name, value)) = line.split_once(':') {
231            current = Some((name.trim().to_ascii_lowercase(), value.to_string()));
232        }
233    }
234    flush(&mut current, &mut headers);
235    let truncated = body.len() > MAX_BODY_FOR_HANDLER;
236    let mut end = body.len().min(MAX_BODY_FOR_HANDLER);
237    while !body.is_char_boundary(end) {
238        end -= 1;
239    }
240    (headers, body[..end].to_string(), truncated)
241}
242
243/// Dot-stuff a message for DATA and terminate it (RFC 5321 4.5.2).
244pub fn dot_stuff(message: &str) -> Vec<u8> {
245    let mut out = Vec::with_capacity(message.len() + 8);
246    for line in message.split('\n') {
247        let line = line.strip_suffix('\r').unwrap_or(line);
248        if line.starts_with('.') {
249            out.push(b'.');
250        }
251        out.extend_from_slice(line.as_bytes());
252        out.extend_from_slice(b"\r\n");
253    }
254    // `split` yields a trailing empty piece for a terminating newline; drop its CRLF pair.
255    if message.ends_with('\n') {
256        out.truncate(out.len() - 2);
257    }
258    out.extend_from_slice(b".\r\n");
259    out
260}