mirror of
https://github.com/chatmail/relay.git
synced 2026-09-08 00:13:14 +00:00
feat: Initial implementation
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
This commit is contained in:
committed by
Jagoda Estera Ślązak
parent
4185ebe3c5
commit
6340f479ae
@@ -0,0 +1,73 @@
|
||||
//! Configuration file handling for filtermail.
|
||||
|
||||
use serde::{Deserialize, Deserializer};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Chatmail configuration subset used by filtermail.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Config {
|
||||
pub filtermail_smtp_port: u16,
|
||||
pub filtermail_smtp_port_incoming: u16,
|
||||
pub postfix_reinject_port: u16,
|
||||
pub postfix_reinject_port_incoming: u16,
|
||||
pub max_message_size: usize,
|
||||
pub max_user_send_per_minute: usize,
|
||||
#[serde(default, deserialize_with = "deserialize_sequence")]
|
||||
pub passthrough_senders: Vec<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_sequence")]
|
||||
pub passthrough_recipients: Vec<String>,
|
||||
mail_domain: String,
|
||||
mailboxes_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct ConfigWrapper {
|
||||
// The whole actual config is under `params` section.
|
||||
pub params: Config,
|
||||
}
|
||||
|
||||
/// Custom deserializer to parse space-separated strings into [`Vec<String>`].
|
||||
fn deserialize_sequence<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s: Option<String> = Deserialize::deserialize(deserializer)?;
|
||||
Ok(match s {
|
||||
Some(v) => v
|
||||
.split(' ')
|
||||
.map(|item| item.trim().to_string())
|
||||
.filter(|item| !item.is_empty())
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Load configuration from a file.
|
||||
pub fn from_file(path: impl AsRef<Path>) -> Result<Self, crate::error::Error> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let wrapped_config: ConfigWrapper = serini::from_str(&content)?;
|
||||
Ok(wrapped_config.params)
|
||||
}
|
||||
|
||||
/// Get the mailboxes directory, using defaulting to `/home/vmail/mail/<mail_domain>` if not set.
|
||||
fn mailboxes_dir(&self) -> PathBuf {
|
||||
match &self.mailboxes_dir {
|
||||
Some(dir) => dir.clone(),
|
||||
None => PathBuf::from(format!("/home/vmail/mail/{}", self.mail_domain)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if not encrypted mail is allowed for the given address.
|
||||
pub fn is_cleartext_ok(&self, addr: &str) -> bool {
|
||||
if addr.is_empty() || !addr.contains('@') || addr.contains('/') {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut enforce_e2ee = self.mailboxes_dir();
|
||||
enforce_e2ee.push(addr);
|
||||
enforce_e2ee.push("enforceE2EEincoming");
|
||||
|
||||
!enforce_e2ee.exists()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//! Error types.
|
||||
|
||||
/// Error type for filtermail.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum Error {
|
||||
#[error("Chatmail config is invalid: {0}")]
|
||||
Config(#[from] serini::Error),
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("OpenPGP packet header is truncated - can't validate!")]
|
||||
TruncatedHeader,
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//! Module for handling incoming SMTP messages.
|
||||
|
||||
use crate::ENCRYPTION_NEEDED_523;
|
||||
use crate::config::Config;
|
||||
use crate::message::{check_encrypted, is_securejoin};
|
||||
use crate::smtp_server::SmtpHandler;
|
||||
use async_trait::async_trait;
|
||||
use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
|
||||
use mailparse::{MailHeaderMap, parse_mail};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use crate::smtp_server::Envelope;
|
||||
use crate::utils::{extract_address, format_smtp_error};
|
||||
|
||||
/// Handler for incoming SMTP messages.
|
||||
pub struct IncomingBeforeQueueHandler {
|
||||
config: Arc<Config>,
|
||||
}
|
||||
|
||||
impl IncomingBeforeQueueHandler {
|
||||
pub fn new(config: Config) -> Self {
|
||||
Self {
|
||||
config: Arc::new(config),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SmtpHandler for IncomingBeforeQueueHandler {
|
||||
fn handle_mail(&self, _address: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_data(&self, envelope: &Envelope) -> Result<(), String> {
|
||||
log::info!("Processing DATA message from {}", envelope.mail_from);
|
||||
|
||||
let message = match parse_mail(&envelope.data) {
|
||||
Ok(m) => m,
|
||||
Err(e) => return Err(format!("500 Failed to parse message: {}", e)),
|
||||
};
|
||||
|
||||
let mail_encrypted = check_encrypted(&message, false);
|
||||
log::debug!("mail_encrypted: {}", mail_encrypted);
|
||||
log::debug!("is_securejoin: {}", is_securejoin(&message));
|
||||
|
||||
// Allow encrypted or securejoin messages
|
||||
if mail_encrypted || is_securejoin(&message) {
|
||||
log::info!("Incoming: Filtering encrypted mail.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log::info!("Incoming: Filtering unencrypted mail.");
|
||||
|
||||
// Allow cleartext mailer-daemon messages
|
||||
if let Some(auto_submitted) = message.headers.get_first_value("Auto-Submitted")
|
||||
&& !auto_submitted.is_empty()
|
||||
{
|
||||
let from_header = message
|
||||
.headers
|
||||
.get_first_value("From")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if let Some(from_addr) = extract_address(&from_header)
|
||||
&& from_addr.to_lowercase().starts_with("mailer-daemon@")
|
||||
&& message.ctype.mimetype == "multipart/report"
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
for recipient in &envelope.rcpt_to {
|
||||
if !self.config.is_cleartext_ok(recipient) {
|
||||
log::info!("Rejected unencrypted mail.");
|
||||
return Err(ENCRYPTION_NEEDED_523.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reinject_mail(&self, envelope: &Envelope) -> Result<(), String> {
|
||||
log::info!("Re-injecting the mail that passed checks");
|
||||
|
||||
let mailer = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous("localhost")
|
||||
.port(self.config.postfix_reinject_port_incoming)
|
||||
.build();
|
||||
|
||||
let envelope_data = lettre::address::Envelope::new(
|
||||
Some(
|
||||
envelope
|
||||
.mail_from
|
||||
.parse()
|
||||
.map_err(|e| format!("Invalid from address: {}", e))?,
|
||||
),
|
||||
envelope
|
||||
.rcpt_to
|
||||
.iter()
|
||||
.map(|addr| addr.parse())
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| format!("Invalid to address: {}", e))?,
|
||||
)
|
||||
.map_err(|e| format!("Failed to create envelope: {}", e))?;
|
||||
|
||||
mailer
|
||||
.send_raw(&envelope_data, &envelope.data)
|
||||
.await
|
||||
.map_err(format_smtp_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
mod config;
|
||||
pub(crate) mod error;
|
||||
pub(crate) mod inbound;
|
||||
pub(crate) mod message;
|
||||
pub(crate) mod openpgp;
|
||||
pub(crate) mod outbound;
|
||||
pub(crate) mod rate_limiter;
|
||||
pub(crate) mod smtp_server;
|
||||
pub(crate) mod utils;
|
||||
|
||||
use config::Config;
|
||||
use env_logger::Env;
|
||||
use inbound::IncomingBeforeQueueHandler;
|
||||
use outbound::OutgoingBeforeQueueHandler;
|
||||
use smtp_server::run_smtp_server;
|
||||
use std::env;
|
||||
use std::process;
|
||||
use std::sync::Arc;
|
||||
|
||||
const ENCRYPTION_NEEDED_523: &str = "523 Encryption Needed: Invalid Unencrypted Mail";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// default to info level
|
||||
let env = Env::new().filter_or("RUST_LOG", "info");
|
||||
env_logger::Builder::from_env(env)
|
||||
// disable timestamps - automatically added by systemd
|
||||
.format_timestamp(None)
|
||||
.init();
|
||||
|
||||
let args: Vec<String> = env::args().collect();
|
||||
if args.len() != 3 {
|
||||
eprintln!("Usage: {} <config_file> <mode>", args[0]);
|
||||
eprintln!(" mode: incoming or outgoing");
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
let config_path = &args[1];
|
||||
let mode = &args[2];
|
||||
|
||||
if mode != "incoming" && mode != "outgoing" {
|
||||
eprintln!("Error: mode must be 'incoming' or 'outgoing'");
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
let config = match Config::from_file(config_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to read config: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
if mode == "outgoing" {
|
||||
let handler = Arc::new(OutgoingBeforeQueueHandler::new(config.clone()));
|
||||
let addr = format!("127.0.0.1:{}", config.filtermail_smtp_port);
|
||||
let max_size = config.max_message_size;
|
||||
log::debug!("Outgoing SMTP server listening on {}", addr);
|
||||
|
||||
if let Err(e) = run_smtp_server(&addr, handler, max_size).await {
|
||||
eprintln!("Server error: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
} else {
|
||||
let handler = Arc::new(IncomingBeforeQueueHandler::new(config.clone()));
|
||||
let addr = format!("127.0.0.1:{}", config.filtermail_smtp_port_incoming);
|
||||
let max_size = config.max_message_size;
|
||||
log::debug!("Incoming SMTP server listening on {}", addr);
|
||||
|
||||
if let Err(e) = run_smtp_server(&addr, handler, max_size).await {
|
||||
eprintln!("Server error: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Message-related checks.
|
||||
|
||||
use crate::openpgp::check_armored_payload;
|
||||
use mailparse::MailHeaderMap;
|
||||
|
||||
/// Check if message is a secure-join message (vc-request or vg-request)
|
||||
pub fn is_securejoin(mail: &mailparse::ParsedMail) -> bool {
|
||||
// Check for secure-join header
|
||||
let secure_join = mail.headers.get_first_value("Secure-Join");
|
||||
if let Some(ref val) = secure_join {
|
||||
if val != "vc-request" && val != "vg-request" {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must be multipart
|
||||
if mail.subparts.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must have only one part
|
||||
if mail.subparts.len() != 1 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let part = &mail.subparts[0];
|
||||
|
||||
// Part must not be multipart
|
||||
if !part.subparts.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Part must be text/plain
|
||||
if part.ctype.mimetype != "text/plain" {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check payload content
|
||||
let payload = match part.get_body() {
|
||||
Ok(p) => p.trim().to_lowercase(),
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
payload == "secure-join: vc-request" || payload == "secure-join: vg-request"
|
||||
}
|
||||
|
||||
/// Check that the message is an OpenPGP-encrypted message
|
||||
///
|
||||
/// MIME structure must correspond to RFC3156
|
||||
pub fn check_encrypted(mail: &mailparse::ParsedMail, outgoing: bool) -> bool {
|
||||
if mail.subparts.is_empty() {
|
||||
log::debug!("check_encrypted: not multipart");
|
||||
return false;
|
||||
}
|
||||
if !mail
|
||||
.ctype
|
||||
.mimetype
|
||||
.eq_ignore_ascii_case("multipart/encrypted")
|
||||
{
|
||||
log::debug!("check_encrypted: not multipart/encrypted");
|
||||
return false;
|
||||
}
|
||||
for (part_idx, part) in mail.subparts.iter().enumerate() {
|
||||
// Each part must not be multipart
|
||||
if !part.subparts.is_empty() {
|
||||
log::debug!("check_encrypted: part of multipart/encrypted is itself multipart");
|
||||
return false;
|
||||
}
|
||||
|
||||
if part_idx == 0 {
|
||||
// First part must be application/pgp-encrypted
|
||||
if !part
|
||||
.ctype
|
||||
.mimetype
|
||||
.eq_ignore_ascii_case("application/pgp-encrypted")
|
||||
{
|
||||
log::debug!(
|
||||
"check_encrypted: first part not application/pgp-encrypted, got: {}",
|
||||
part.ctype.mimetype
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Payload must be "Version: 1"
|
||||
let payload = match part.get_body() {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
log::debug!("check_encrypted: failed to get body of first part");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if payload.trim() != "Version: 1" {
|
||||
log::debug!(
|
||||
"check_encrypted: first part payload not 'Version: 1', got {}",
|
||||
payload.trim()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} else if part_idx == 1 {
|
||||
// Second part must be application/octet-stream
|
||||
if part.ctype.mimetype != "application/octet-stream" {
|
||||
log::debug!(
|
||||
"check_encrypted: second part not application/octet-stream, got: {}",
|
||||
part.ctype.mimetype
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check the armored payload
|
||||
let payload = match part.get_body() {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
log::debug!("check_encrypted: failed to get body of second part");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if !check_armored_payload(payload, outgoing) {
|
||||
log::debug!("check_encrypted: armored payload check failed");
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
log::debug!("check_encrypted: more than two parts found");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Check if recipient matches a passthrough pattern
|
||||
pub fn recipient_matches_passthrough(recipient: &str, passthrough_recipients: &[String]) -> bool {
|
||||
for addr in passthrough_recipients {
|
||||
if recipient == addr {
|
||||
return true;
|
||||
}
|
||||
if addr.starts_with('@') && recipient.ends_with(addr) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use mailparse::parse_mail;
|
||||
use rstest::*;
|
||||
use testresult::TestResult;
|
||||
|
||||
#[fixture]
|
||||
fn passthrough_recipients() -> Vec<String> {
|
||||
vec!["pass@example.org".to_string(), "@example.com".to_string()]
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::asm("test_data/asm.eml", false)]
|
||||
#[case::encrypted("test_data/encrypted.eml", false)]
|
||||
#[case::fake_encrypted("test_data/fake-encrypted.eml", false)]
|
||||
#[case::literal("test_data/literal.eml", false)]
|
||||
#[case::mailer_daemon("test_data/mailer-daemon.eml", false)]
|
||||
#[case::mdn("test_data/mdn.eml", false)]
|
||||
#[case::plain("test_data/plain.eml", false)]
|
||||
#[case::securejoin_vc("test_data/securejoin-vc.eml", true)]
|
||||
#[case::securejoin_vc_fake("test_data/securejoin-vc-fake.eml", false)]
|
||||
fn test_is_securejoin(#[case] file: &str, #[case] expected: bool) -> TestResult {
|
||||
let raw_email = std::fs::read_to_string(file)?;
|
||||
let parsed = parse_mail(raw_email.as_bytes())?;
|
||||
assert_eq!(is_securejoin(&parsed), expected);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::asm("test_data/asm.eml", false)]
|
||||
#[case::encrypted("test_data/encrypted.eml", true)]
|
||||
#[case::fake_encrypted("test_data/fake-encrypted.eml", false)]
|
||||
#[case::literal("test_data/literal.eml", false)]
|
||||
#[case::mailer_daemon("test_data/mailer-daemon.eml", false)]
|
||||
#[case::mdn("test_data/mdn.eml", false)]
|
||||
#[case::plain("test_data/plain.eml", false)]
|
||||
#[case::securejoin_vc("test_data/securejoin-vc.eml", false)]
|
||||
#[case::securejoin_vc_fake("test_data/securejoin-vc-fake.eml", false)]
|
||||
fn test_check_encrypted(#[case] file: &str, #[case] expected: bool) -> TestResult {
|
||||
let raw_email = std::fs::read_to_string(file)?;
|
||||
let parsed = parse_mail(raw_email.as_bytes())?;
|
||||
assert_eq!(check_encrypted(&parsed, false), expected);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case("pass@example.org", true)]
|
||||
#[case("other@example.org", false)]
|
||||
#[case("anything@example.com", true)]
|
||||
#[case("anything@sub.example.com", false)]
|
||||
fn test_recipient_matches_passthrough(
|
||||
#[case] recipient: &str,
|
||||
#[case] expected: bool,
|
||||
passthrough_recipients: Vec<String>,
|
||||
) {
|
||||
let result = recipient_matches_passthrough(recipient, &passthrough_recipients);
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
//! OpenPGP payload checker.
|
||||
|
||||
use crate::error;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
|
||||
/// Tries to get the byte `$idx` of the array slice `$payload`.
|
||||
///
|
||||
/// Returns [`error::Error::TruncatedHeader`] in the outer function, if `$idx` is out of range.
|
||||
macro_rules! get_byte {
|
||||
($payload:expr, $idx:expr) => {
|
||||
*$payload.get($idx).ok_or(error::Error::TruncatedHeader)?
|
||||
};
|
||||
}
|
||||
|
||||
/// Checks the OpenPGP payload.
|
||||
///
|
||||
/// OpenPGP payload must consist only of `PKESK` and `SKESK` packets terminated by a single `SEIPD` packet.
|
||||
///
|
||||
/// Returns `Ok(true)` if OpenPGP payload is correct, `Ok(false)` otherwise.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an [`error::Error::TruncatedHeader`] if the OpenPGP packet header is truncated.
|
||||
fn check_openpgp_payload(payload: &[u8]) -> Result<bool, error::Error> {
|
||||
let mut i: usize = 0;
|
||||
while i < payload.len() {
|
||||
// Only OpenPGP format is allowed.
|
||||
if (get_byte!(payload, i) & 0xC0) != 0xC0 {
|
||||
log::debug!("check_openpgp_payload: i={i} Not OpenPGP format");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let packet_type_id = get_byte!(payload, i) & 0x3F;
|
||||
i += 1;
|
||||
|
||||
while get_byte!(payload, i) >= 224 && get_byte!(payload, i) < 255 {
|
||||
// Partial body length.
|
||||
let partial_length = 1usize << (get_byte!(payload, i) & 0x1F);
|
||||
i += 1 + partial_length;
|
||||
}
|
||||
|
||||
let body_len: usize;
|
||||
if get_byte!(payload, i) < 192 {
|
||||
// One-octet length.
|
||||
body_len = get_byte!(payload, i) as usize;
|
||||
i += 1;
|
||||
} else if get_byte!(payload, i) < 224 {
|
||||
// Two-octet length.
|
||||
body_len = (((get_byte!(payload, i) as usize) - 192) << 8)
|
||||
+ (get_byte!(payload, i + 1) as usize)
|
||||
+ 192;
|
||||
i += 2;
|
||||
} else if get_byte!(payload, i) == 255 {
|
||||
// Five-octet length.
|
||||
body_len = ((get_byte!(payload, i + 1) as usize) << 24)
|
||||
| ((get_byte!(payload, i + 2) as usize) << 16)
|
||||
| ((get_byte!(payload, i + 3) as usize) << 8)
|
||||
| (get_byte!(payload, i + 4) as usize);
|
||||
i += 5;
|
||||
} else {
|
||||
// Impossible, partial body length was processed above.
|
||||
log::debug!("check_openpgp_payload: i={i} Invalid body length");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
i += body_len;
|
||||
|
||||
if i == payload.len() {
|
||||
// Last packet should be
|
||||
// Symmetrically Encrypted and Integrity Protected Data Packet (SEIPD)
|
||||
//
|
||||
// This is the only place where this function may return `True`.
|
||||
log::debug!(
|
||||
"check_openpgp_payload: i={i} packat_type_id={}",
|
||||
packet_type_id
|
||||
);
|
||||
return Ok(packet_type_id == 18);
|
||||
} else if ![1, 3].contains(&packet_type_id) {
|
||||
// All packets except the last one must be either
|
||||
// Public-Key Encrypted Session Key Packet (PKESK)
|
||||
// or
|
||||
// Symmetric-Key Encrypted Session Key Packet (SKESK)
|
||||
log::debug!(
|
||||
"check_openpgp_payload: i={i} packet_type_id={}",
|
||||
packet_type_id
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Check the armored PGP message for invalid content.
|
||||
///
|
||||
/// Returns `true` if the `payload` is a valid PGP message,
|
||||
/// `outgoing` informs whether the message is outgoing or incoming
|
||||
pub fn check_armored_payload(mut payload: String, outgoing: bool) -> bool {
|
||||
const PREFIX: &str = "-----BEGIN PGP MESSAGE-----\r\n";
|
||||
if !payload.starts_with(PREFIX) {
|
||||
log::debug!("check_armored_payload: Did not find PGP MESSAGE prefix");
|
||||
return false;
|
||||
}
|
||||
payload = payload[PREFIX.len()..].to_string();
|
||||
|
||||
while payload.ends_with("\r\n") {
|
||||
payload.truncate(payload.len() - 2);
|
||||
}
|
||||
const SUFFIX: &str = "-----END PGP MESSAGE-----";
|
||||
if !payload.ends_with(SUFFIX) {
|
||||
log::debug!("check_armored_payload: Did not find PGP MESSAGE suffix");
|
||||
return false;
|
||||
}
|
||||
payload.truncate(payload.len() - SUFFIX.len());
|
||||
|
||||
const VERSION_COMMENT: &str = "Version: ";
|
||||
if payload.starts_with(VERSION_COMMENT) {
|
||||
// Disallow comments in outgoing messages
|
||||
if outgoing {
|
||||
log::debug!("check_armored_payload: Comment found in outgoing message");
|
||||
return false;
|
||||
}
|
||||
// Remove comments from incoming messages
|
||||
if let Some((_, right)) = payload.split_once("\r\n") {
|
||||
payload = right.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
while payload.starts_with("\r\n") {
|
||||
payload = payload[2..].to_string();
|
||||
}
|
||||
|
||||
// Remove CRC24.
|
||||
if let Some((left, _)) = payload.rsplit_once('=') {
|
||||
payload = left.to_string();
|
||||
}
|
||||
|
||||
payload = payload.replace(['\r', '\n'], "");
|
||||
let payload = match BASE64_STANDARD.decode(payload.as_bytes()) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
log::debug!("check_armored_payload: Base64 decoding failed");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
check_openpgp_payload(&payload).unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::*;
|
||||
|
||||
#[rstest]
|
||||
#[case::valid(r#"-----BEGIN PGP MESSAGE-----
|
||||
|
||||
wU4DhW3gBZ/VvCYSAQdA8bMs2spwbKdGjVsL1ByPkNrqD7frpB73maeL6I6SzDYg
|
||||
O5G53tv339RdKq3WRcCtEEvxjHlUx2XNwXzC04BpmfvBTgNfPUyLDzjXnxIBB0Ae
|
||||
8ymwGvXMCCimHXN0Dg8Ui62KOi03h0UgheoHWovJSCDF4CKre/xtFr3nL7lq/PKI
|
||||
JsjVNz7/RK9FSXF6WwfONtLCyQGEuVAsB/KXfCBEyfKhaMwGHvhujRidGW5uV1no
|
||||
lMGl3ODmo29Lgeu2uSE7EpJRZoe6hU6ddmBkqxax61ZtkaFlGFFpdo2K8balNNdz
|
||||
ZsJ/9mmI9x3oOJ4/l1nhQbUO9ADbs7gJhFdV5Qkp30b5fCI7bU+aoe1ccBbLe/WM
|
||||
YUty1PqcuQT7XjA+XmYuL261tvW8pBetT+i33/E2d8PzzYt2IuK9qeevyS+yxdwA
|
||||
kfwejFWzzsUlJaDxs1x4XOxkMgSj+jo+g12dFOb7fyClsAnq23iDb8AuaT/BScAI
|
||||
+lO+gher69+6LmM7VGHLG5k762J1jTaQCaKt1s8TAWV99Eo4491vL6fyvk3l/Cfg
|
||||
RXSwiWFgj19Pn0Rq7CD9v22UE2vdUMBTcV4aw79mClk1YQ23jbF0y5DCjPdJ62Zo
|
||||
tskBgFt3NoWV80jZ76zIBLrrjLwCCll8JjJtFwSkt2GX5RFBsVa4A8IDht9RtEk7
|
||||
rrHgbSZQfkauEi/mH3/6CDZoLqSHudUZ7d4MaJwun1TkFYGe2ORwGJd4OBj3oGJp
|
||||
H8YBwCpk///L/fKjX0Gg3M8nrpM4wrRFhPKidAgO/kcm25X4+ZHlVkWBTCt5RWKI
|
||||
fHh6oLDZCqCfcgMkE1KKmwfIHaUkhq5BPRigwy6i5dh1DM4+1UCLh3dxzVbqE9b9
|
||||
61NB19nXdRtDA2sOUnj9ve6m/wEPyCb6/zBQZqvCBYb1/AjdXpUrFT+DbpfyxaXN
|
||||
XfhDVb5mNqNM/IVj0V5fvTc6vOfYbzQtPm10H+FdWWfb+rJRfyC3MA2w2IqstFe3
|
||||
w3bu2iE6CQvSqRvge+ZqLKt/NqYwOURiUmpuklbl3kPJ97+mfKWoiqk8Iz1VY+bb
|
||||
NMUC7aoGv+jcoj+WS6PYO8N6BeRVUUB3ZJSf8nzjgxm1/BcM+UD3BPrlhT11ODRs
|
||||
baifGbprMWwt3dhb8cQgRT8GPdpO1OsDkzL6iikMjLHWWiA99GV6ruiHsIPw6boW
|
||||
A6/uSOskbDHOROotKmddGTBd0iiHXAoQsJFt1ZjUkt6EHrgWs+GAvrvKpXs1mrz8
|
||||
uj3GwEFrHS+Xuf2UDgpszYT3hI2cL/kUtGakVR7m7vVMZqXBUbZdGAEb1PZNPwsI
|
||||
E4aMK02+EVB+tSN4Fzj99N2YD0inVYt+oPjr2tHhUS6aSGBNS/48Ki47DOg4Sxkn
|
||||
lkOWnEbCD+XTnbDd
|
||||
=agR5
|
||||
-----END PGP MESSAGE-----"#, (true, true))]
|
||||
#[case::with_comment(r#"-----BEGIN PGP MESSAGE-----
|
||||
Version: 1
|
||||
wU4DhW3gBZ/VvCYSAQdA8bMs2spwbKdGjVsL1ByPkNrqD7frpB73maeL6I6SzDYg
|
||||
O5G53tv339RdKq3WRcCtEEvxjHlUx2XNwXzC04BpmfvBTgNfPUyLDzjXnxIBB0Ae
|
||||
8ymwGvXMCCimHXN0Dg8Ui62KOi03h0UgheoHWovJSCDF4CKre/xtFr3nL7lq/PKI
|
||||
JsjVNz7/RK9FSXF6WwfONtLCyQGEuVAsB/KXfCBEyfKhaMwGHvhujRidGW5uV1no
|
||||
lMGl3ODmo29Lgeu2uSE7EpJRZoe6hU6ddmBkqxax61ZtkaFlGFFpdo2K8balNNdz
|
||||
ZsJ/9mmI9x3oOJ4/l1nhQbUO9ADbs7gJhFdV5Qkp30b5fCI7bU+aoe1ccBbLe/WM
|
||||
YUty1PqcuQT7XjA+XmYuL261tvW8pBetT+i33/E2d8PzzYt2IuK9qeevyS+yxdwA
|
||||
kfwejFWzzsUlJaDxs1x4XOxkMgSj+jo+g12dFOb7fyClsAnq23iDb8AuaT/BScAI
|
||||
+lO+gher69+6LmM7VGHLG5k762J1jTaQCaKt1s8TAWV99Eo4491vL6fyvk3l/Cfg
|
||||
RXSwiWFgj19Pn0Rq7CD9v22UE2vdUMBTcV4aw79mClk1YQ23jbF0y5DCjPdJ62Zo
|
||||
tskBgFt3NoWV80jZ76zIBLrrjLwCCll8JjJtFwSkt2GX5RFBsVa4A8IDht9RtEk7
|
||||
rrHgbSZQfkauEi/mH3/6CDZoLqSHudUZ7d4MaJwun1TkFYGe2ORwGJd4OBj3oGJp
|
||||
H8YBwCpk///L/fKjX0Gg3M8nrpM4wrRFhPKidAgO/kcm25X4+ZHlVkWBTCt5RWKI
|
||||
fHh6oLDZCqCfcgMkE1KKmwfIHaUkhq5BPRigwy6i5dh1DM4+1UCLh3dxzVbqE9b9
|
||||
61NB19nXdRtDA2sOUnj9ve6m/wEPyCb6/zBQZqvCBYb1/AjdXpUrFT+DbpfyxaXN
|
||||
XfhDVb5mNqNM/IVj0V5fvTc6vOfYbzQtPm10H+FdWWfb+rJRfyC3MA2w2IqstFe3
|
||||
w3bu2iE6CQvSqRvge+ZqLKt/NqYwOURiUmpuklbl3kPJ97+mfKWoiqk8Iz1VY+bb
|
||||
NMUC7aoGv+jcoj+WS6PYO8N6BeRVUUB3ZJSf8nzjgxm1/BcM+UD3BPrlhT11ODRs
|
||||
baifGbprMWwt3dhb8cQgRT8GPdpO1OsDkzL6iikMjLHWWiA99GV6ruiHsIPw6boW
|
||||
A6/uSOskbDHOROotKmddGTBd0iiHXAoQsJFt1ZjUkt6EHrgWs+GAvrvKpXs1mrz8
|
||||
uj3GwEFrHS+Xuf2UDgpszYT3hI2cL/kUtGakVR7m7vVMZqXBUbZdGAEb1PZNPwsI
|
||||
E4aMK02+EVB+tSN4Fzj99N2YD0inVYt+oPjr2tHhUS6aSGBNS/48Ki47DOg4Sxkn
|
||||
lkOWnEbCD+XTnbDd
|
||||
=agR5
|
||||
-----END PGP MESSAGE-----"#, (false, true))]
|
||||
#[case::invalid_base64(r#"-----BEGIN PGP MESSAGE-----
|
||||
|
||||
wU4DhW3gBZ/VvCYSAQdA8bMs2spwbKdGjVsL1ByPkNrqD7frpB73maeL6I6SzDYg
|
||||
O5G53tv339RdKq3WRcCtEEvxjHlUx2XNwXzC04BpmfvBTgNfPUyLDzjXnxIBB0Ae
|
||||
8ymwGvXMCCimHXN0Dg8Ui62KOi03h0UgheoHWovJSCDF4CKre/xtFr3nL7lq/PKI
|
||||
JsjVNz7/RK9FSXF6WwfONtLCyQGEuVAsB/KXfCBEyfKhaMwGHvhujRidGW5uV1no
|
||||
lMGl3ODmo29Lgeu2uSE7EpJRZoe6hU6ddmBkqxax61ZtkaFlGFFpdo2K8balNNdz
|
||||
ZsJ/9mmI9x3oOJ4/l1nhQbUO9ADbs7gJhFdV5Qkp30b5fCI7bU+aoe1ccBbLe/WM
|
||||
YUty1PqcuQT7XjA+XmYuL261tvW8pBetT+i33/E2d8PzzYt2IuK9qeevyS+yxdwA
|
||||
kfwejFWzzsUlJaDxs1x4XOxkMgSj+jo+g12dFOb7fyClsAnq23iDb8AuaT/BScAI
|
||||
+lO+gher69+6LmM7VGHLG5k762J1jTaQCaKt1s8TAWV99Eo4491vL6fyvk3l/Cfg
|
||||
RXSwiWFgj19Pn0Rq7CD9v22UE2vdUMBTcV4aw79mClk1YQ23jbF0y5DCjPdJ62Zo
|
||||
tskBgFt3NoWV80jZ76zIBLrrjLwCCll8JjJtFwSkt2GX5RFBsVa4A8IDht9RtEk7
|
||||
rrHgbSZQfkauEi/mH3/6CDZoLqSHudUZ7d4MaJwun1TkFYGe2ORwGJd4OBj3oGJp
|
||||
H8YBwCpk///L/fKjX0Gg3M8nrpM4wrRFhPKidAgO/kcm25X4+ZHlVkWBTCt5RWKI
|
||||
fHh6oLDZCqCfcgMkE1KKmwfIHaUkhq5BPRigwy6i5dh1DM4+1UCLh3dxzVbqE9b9
|
||||
61NB19nXdRtDA2sOUnj9ve6m/wEPyCb6/zBQZqvCBYb1/AjdXpUrFT+DbpfyxaXN
|
||||
XfhDVb5mNqNM/IVj0V5fvTc6vOfYbzQtPm10H+FdWWfb+rJRfyC3MA2w2IqstFe3
|
||||
w3bu2iE6CQvSqRvge+ZqLKt/NqYwOURiUmpuklbl3kPJ97+mfKWoiqk8Iz1VY+bb
|
||||
NMUC7aoGv+jcoj+WS6PYO8N6BeRVUUB3ZJSf8nzjgxm1/BcM+UD3BPrlhT11ODRs
|
||||
baifGbprMWwt3dhb8cQgRT8GPdpO1OsDkzL6iikMjLHWWiA99GV6ruiHsIPw6boW
|
||||
A6/uSOskbDHOROotKmddGTBd0iiHXAoQsJFt1ZjUkt6EHrgWs+GAvrvKpXs1mrz8
|
||||
uj3GwEFrHS+Xuf2UDgpszYT3hI2cL/kUtGakVR7m7vVMZqXBUbZdGAEb1PZNPwsI
|
||||
E4aMK02+EVB+tSN4Fzj99N2YD0inVYt+oPjr2tHhUS6aSGBNS/48Ki47DOg4Sxkn
|
||||
lkOWnEbCD+XTnbDd=
|
||||
=agR5
|
||||
-----END PGP MESSAGE-----"#, (false, false))]
|
||||
#[case::invalid_non_pgp_base64(r#"-----BEGIN PGP MESSAGE-----
|
||||
|
||||
RGVsdGEgQ2hhdCBpcyBhIHJlbGlhYmxlLCBkZWNlbnRyYWxpemVkIGFuZCBzZWN1cmUgaW5zdGFu
|
||||
dCBtZXNzYWdpbmcgYXBwLCBhdmFpbGFibGUgZm9yIG1vYmlsZSBhbmQgZGVza3RvcCBwbGF0Zm9y
|
||||
bXMuCgogICAgSW5zdGFudCBjcmVhdGlvbiBvZiBwcml2YXRlIGNoYXQgcHJvZmlsZXMgd2l0aCBz
|
||||
ZWN1cmUgYW5kIGludGVyb3BlcmFibGUgY2hhdG1haWwgcmVsYXlzIHRoYXQgb2ZmZXIgaW5zdGFu
|
||||
dCBtZXNzYWdlIGRlbGl2ZXJ5LCBhbmQgUHVzaCBOb3RpZmljYXRpb25zIGZvciBpT1MgYW5kIEFu
|
||||
ZHJvaWQgZGV2aWNlcy4KCiAgICBQZXJ2YXNpdmUgbXVsdGktcHJvZmlsZSBhbmQgbXVsdGktZGV2
|
||||
aWNlIHN1cHBvcnQgb24gYWxsIHBsYXRmb3JtcyBhbmQgYmV0d2VlbiBkaWZmZXJlbnQgY2hhdG1h
|
||||
aWwgYXBwcy4KCiAgICBJbnRlcmFjdGl2ZSBpbi1jaGF0IGFwcHMgZm9yIGdhbWluZyBhbmQgY29s
|
||||
bGFib3JhdGlvbgoKICAgIEF1ZGl0ZWQgZW5kLXRvLWVuZCBlbmNyeXB0aW9uIHNhZmUgYWdhaW5z
|
||||
dCBuZXR3b3JrIGFuZCBzZXJ2ZXIgYXR0YWNrcy4KCiAgICBGcmVlIGFuZCBPcGVuIFNvdXJjZSBz
|
||||
b2Z0d2FyZSwgYm90aCBhcHAgYW5kIHNlcnZlciBzaWRlLCBidWlsdCBvbiBJbnRlcm5ldCBTdGFu
|
||||
ZGFyZHMuCgo=
|
||||
=4cf0a3
|
||||
-----END PGP MESSAGE-----"#, (false, false))]
|
||||
#[case::invalid_cleartext(r#"-----BEGIN PGP MESSAGE-----
|
||||
|
||||
Definitely not base64 encoded PGP message content.
|
||||
-----END PGP MESSAGE-----"#, (false, false))]
|
||||
#[case::invalid_no_begin(r#"-----END PGP MESSAGE-----"#, (false, false))]
|
||||
#[case::invalid_no_end(r#"-----BEGIN PGP MESSAGE-----"#, (false, false))]
|
||||
fn test_check_armored_payload(#[case] pgp_message: &str, #[case] expected: (bool, bool)) {
|
||||
let (expected_outgoing, expected_incoming) = expected;
|
||||
|
||||
let result = check_armored_payload(pgp_message.replace('\n', "\r\n").to_string(), true);
|
||||
assert_eq!(result, expected_outgoing);
|
||||
|
||||
let result = check_armored_payload(pgp_message.replace('\n', "\r\n").to_string(), false);
|
||||
assert_eq!(result, expected_incoming);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Module for handling outgoing SMTP messages.
|
||||
|
||||
use crate::ENCRYPTION_NEEDED_523;
|
||||
use crate::config::Config;
|
||||
use crate::message::{check_encrypted, is_securejoin, recipient_matches_passthrough};
|
||||
use crate::rate_limiter::SendRateLimiter;
|
||||
pub use crate::smtp_server::Envelope;
|
||||
use crate::smtp_server::SmtpHandler;
|
||||
use crate::utils::{extract_address, format_smtp_error};
|
||||
use async_trait::async_trait;
|
||||
use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
|
||||
use mailparse::{MailHeaderMap, parse_mail};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Handler for outgoing SMTP messages.
|
||||
pub struct OutgoingBeforeQueueHandler {
|
||||
config: Arc<Config>,
|
||||
send_rate_limiter: Arc<Mutex<SendRateLimiter>>,
|
||||
}
|
||||
|
||||
impl OutgoingBeforeQueueHandler {
|
||||
pub fn new(config: Config) -> Self {
|
||||
Self {
|
||||
config: Arc::new(config),
|
||||
send_rate_limiter: Arc::new(Mutex::new(SendRateLimiter::default())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SmtpHandler for OutgoingBeforeQueueHandler {
|
||||
fn handle_mail(&self, address: &str) -> Result<(), String> {
|
||||
log::info!("handle_MAIL from {}", address);
|
||||
|
||||
let parts: Vec<&str> = address.split('@').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(format!("500 Invalid from address <{}>", address));
|
||||
}
|
||||
|
||||
let max_sent = self.config.max_user_send_per_minute;
|
||||
let mut limiter = self.send_rate_limiter.lock().unwrap();
|
||||
if !limiter.is_sending_allowed(address, max_sent) {
|
||||
log::debug!("Rate limit exceeded for {}", address);
|
||||
return Err(format!("450 4.7.1: Too much mail from {}", address));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_data(&self, envelope: &Envelope) -> Result<(), String> {
|
||||
log::info!("Processing DATA message from {}", envelope.mail_from);
|
||||
|
||||
let message = match parse_mail(&envelope.data) {
|
||||
Ok(m) => m,
|
||||
Err(e) => return Err(format!("500 Failed to parse message: {}", e)),
|
||||
};
|
||||
|
||||
let mail_encrypted = check_encrypted(&message, true);
|
||||
|
||||
let from_header = message
|
||||
.headers
|
||||
.get_first_value("From")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let from_addr = extract_address(&from_header)
|
||||
.ok_or(format!("500 Invalid FROM header: {from_header}"))?;
|
||||
|
||||
if !envelope.mail_from.eq_ignore_ascii_case(&from_addr) {
|
||||
return Err(format!(
|
||||
"500 Invalid FROM <{}> for <{}>",
|
||||
from_addr, envelope.mail_from
|
||||
));
|
||||
}
|
||||
|
||||
// Allow encrypted or securejoin messages
|
||||
if mail_encrypted || is_securejoin(&message) {
|
||||
log::info!("Outgoing: Filtering encrypted mail.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log::info!("Outgoing: Filtering unencrypted mail.");
|
||||
|
||||
// Allow passthrough senders
|
||||
if self
|
||||
.config
|
||||
.passthrough_senders
|
||||
.contains(&envelope.mail_from)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Allow self-sent Autocrypt Setup Message
|
||||
if envelope.rcpt_to.len() == 1 && envelope.rcpt_to[0] == envelope.mail_from {
|
||||
let subject = message
|
||||
.headers
|
||||
.get_first_value("Subject")
|
||||
.unwrap_or_default();
|
||||
if subject == "Autocrypt Setup Message" && message.ctype.mimetype == "multipart/mixed" {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
for recipient in &envelope.rcpt_to {
|
||||
if !recipient_matches_passthrough(recipient, &self.config.passthrough_recipients) {
|
||||
log::info!("Rejected unencrypted mail.");
|
||||
return Err(ENCRYPTION_NEEDED_523.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reinject_mail(&self, envelope: &Envelope) -> Result<(), String> {
|
||||
log::info!("Re-injecting the mail that passed checks");
|
||||
|
||||
let mailer = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous("localhost")
|
||||
.port(self.config.postfix_reinject_port)
|
||||
.build();
|
||||
|
||||
let envelope_data = lettre::address::Envelope::new(
|
||||
Some(
|
||||
envelope
|
||||
.mail_from
|
||||
.parse()
|
||||
.map_err(|e| format!("Invalid from address: {}", e))?,
|
||||
),
|
||||
envelope
|
||||
.rcpt_to
|
||||
.iter()
|
||||
.map(|addr| addr.parse())
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| format!("Invalid to address: {}", e))?,
|
||||
)
|
||||
.map_err(|e| format!("Failed to create envelope: {}", e))?;
|
||||
|
||||
mailer
|
||||
.send_raw(&envelope_data, &envelope.data)
|
||||
.await
|
||||
.map_err(format_smtp_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! Module for rate limiting.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
const ONE_MINUTE: Duration = Duration::from_secs(60);
|
||||
|
||||
/// A rate limiter tracking send timestamps per address.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SendRateLimiter {
|
||||
address_timestamps: HashMap<String, Vec<SystemTime>>,
|
||||
}
|
||||
|
||||
impl SendRateLimiter {
|
||||
pub fn is_sending_allowed(&mut self, mail_from: &str, max_send_per_minute: usize) -> bool {
|
||||
self.address_timestamps.retain(|_, timestamps| {
|
||||
timestamps
|
||||
.last()
|
||||
.map(|t| t.elapsed().unwrap_or_default() <= ONE_MINUTE)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
let last = self
|
||||
.address_timestamps
|
||||
.entry(mail_from.to_string())
|
||||
.or_default();
|
||||
last.retain(|&send_time| send_time.elapsed().unwrap_or_default() <= ONE_MINUTE);
|
||||
if last.len() <= max_send_per_minute {
|
||||
last.push(SystemTime::now());
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//! A simplified SMTP server implementation for internal communication.
|
||||
|
||||
use crate::utils::extract_address;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
/// Represents an SMTP envelope with sender, recipients, and raw message data.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Envelope {
|
||||
pub mail_from: String,
|
||||
pub rcpt_to: Vec<String>,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Trait defining the SMTP handler interface.
|
||||
#[async_trait]
|
||||
pub trait SmtpHandler: Send + Sync {
|
||||
/// Handles the MAIL FROM command.
|
||||
fn handle_mail(&self, address: &str) -> Result<(), String>;
|
||||
|
||||
/// Checks the DATA command before reinjection.
|
||||
fn check_data(&self, envelope: &Envelope) -> Result<(), String>;
|
||||
|
||||
/// Reinjects the mail back to postfix.
|
||||
async fn reinject_mail(&self, envelope: &Envelope) -> Result<(), String>;
|
||||
|
||||
/// Handles the DATA command.
|
||||
async fn handle_data(&self, envelope: &Envelope) -> Result<String, String> {
|
||||
log::info!("handle_DATA before-queue");
|
||||
self.check_data(envelope)?;
|
||||
self.reinject_mail(envelope).await.map_err(|e| {
|
||||
log::warn!("Failed to reinject mail: {}", e);
|
||||
e
|
||||
})?;
|
||||
Ok("250 OK".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the SMTP server on the specified address with the given handler and maximum message size.
|
||||
pub async fn run_smtp_server<H>(
|
||||
addr: &str,
|
||||
handler: Arc<H>,
|
||||
max_size: usize,
|
||||
) -> Result<(), Box<dyn std::error::Error>>
|
||||
where
|
||||
H: SmtpHandler + 'static,
|
||||
{
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
// message for backward compatibility with chatmaild tests.
|
||||
log::info!("entering serving loop");
|
||||
|
||||
loop {
|
||||
let (socket, _) = listener.accept().await?;
|
||||
let handler = handler.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(socket, handler, max_size).await {
|
||||
log::error!("Error handling connection: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles an individual SMTP connection.
|
||||
async fn handle_connection<H>(
|
||||
socket: TcpStream,
|
||||
handler: Arc<H>,
|
||||
max_size: usize,
|
||||
) -> Result<(), Box<dyn std::error::Error>>
|
||||
where
|
||||
H: SmtpHandler,
|
||||
{
|
||||
let (reader, mut writer) = socket.into_split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
let mut line = String::new();
|
||||
|
||||
writer.write_all(b"220 filtermail SMTP\r\n").await?;
|
||||
|
||||
let mut envelope = Envelope {
|
||||
mail_from: String::new(),
|
||||
rcpt_to: Vec::new(),
|
||||
data: Vec::new(),
|
||||
};
|
||||
|
||||
'connection: loop {
|
||||
line.clear();
|
||||
let n = reader.read_line(&mut line).await?;
|
||||
if n == 0 {
|
||||
break 'connection;
|
||||
}
|
||||
|
||||
// Remove CRLF
|
||||
// Note: this will kill the connection if any line doesn't end with CRLF.
|
||||
// This is intentional as stray LF most likely means an attempt to exploit the server.
|
||||
let Some(cmd) = line.strip_suffix("\r\n") else {
|
||||
log::warn!("Malformed command without CRLF ending! Closing connection.");
|
||||
break 'connection;
|
||||
};
|
||||
|
||||
log::debug!("Received: {}", cmd);
|
||||
|
||||
if cmd.to_uppercase().starts_with("HELO") || cmd.to_uppercase().starts_with("EHLO") {
|
||||
writer.write_all(b"250 OK\r\n").await?;
|
||||
} else if cmd.to_uppercase().starts_with("MAIL FROM:") {
|
||||
if let Some(from) = extract_address(cmd) {
|
||||
match handler.handle_mail(&from) {
|
||||
Ok(_) => {
|
||||
envelope.mail_from = from;
|
||||
writer.write_all(b"250 OK\r\n").await?;
|
||||
}
|
||||
Err(e) => {
|
||||
writer.write_all(format!("{}\r\n", e).as_bytes()).await?;
|
||||
break 'connection;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::debug!("Invalid MAIL FROM command. Can't extract address.");
|
||||
writer
|
||||
.write_all(b"500 Invalid addreess in MAIL FROM\r\n")
|
||||
.await?;
|
||||
}
|
||||
} else if cmd.to_uppercase().starts_with("RCPT TO:") {
|
||||
if let Some(to) = extract_address(cmd) {
|
||||
envelope.rcpt_to.push(to);
|
||||
writer.write_all(b"250 OK\r\n").await?;
|
||||
}
|
||||
} else if cmd.to_uppercase().starts_with("DATA") {
|
||||
writer
|
||||
.write_all(b"354 End data with <CR><LF>.<CR><LF>\r\n")
|
||||
.await?;
|
||||
let mut data = Vec::new();
|
||||
let mut data_line = String::new();
|
||||
'data_read: loop {
|
||||
data_line.clear();
|
||||
reader.read_line(&mut data_line).await?;
|
||||
|
||||
if data_line == ".\r\n" {
|
||||
break 'data_read;
|
||||
}
|
||||
|
||||
if !data_line.ends_with("\r\n") {
|
||||
log::warn!("Malformed DATA line without CRLF ending! Closing connection.");
|
||||
break 'connection;
|
||||
}
|
||||
|
||||
data.extend_from_slice(data_line.as_bytes());
|
||||
|
||||
if data.len() > max_size {
|
||||
writer
|
||||
.write_all(b"552 Message exceeds maximum size\r\n")
|
||||
.await?;
|
||||
break 'connection;
|
||||
}
|
||||
}
|
||||
|
||||
envelope.data = data;
|
||||
|
||||
// Process the message
|
||||
match handler.handle_data(&envelope).await {
|
||||
Ok(response) => {
|
||||
log::debug!("Sent: {}", response);
|
||||
writer
|
||||
.write_all(format!("{}\r\n", response).as_bytes())
|
||||
.await?;
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("Sent: {}", e);
|
||||
writer.write_all(format!("{}\r\n", e).as_bytes()).await?;
|
||||
}
|
||||
}
|
||||
|
||||
envelope = Envelope {
|
||||
mail_from: String::new(),
|
||||
rcpt_to: Vec::new(),
|
||||
data: Vec::new(),
|
||||
};
|
||||
} else if cmd.to_uppercase().starts_with("QUIT") {
|
||||
writer.write_all(b"221 OK\r\n").await?;
|
||||
break 'connection;
|
||||
} else if cmd.to_uppercase().starts_with("RSET") {
|
||||
envelope = Envelope {
|
||||
mail_from: String::new(),
|
||||
rcpt_to: Vec::new(),
|
||||
data: Vec::new(),
|
||||
};
|
||||
writer.write_all(b"250 OK\r\n").await?;
|
||||
} else if cmd.to_uppercase().starts_with("NOOP") {
|
||||
writer.write_all(b"250 OK\r\n").await?;
|
||||
} else {
|
||||
writer.write_all(b"500 Command not recognized\r\n").await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use mailparse::MailAddr;
|
||||
use std::error::Error;
|
||||
|
||||
/// Extracts the first email address found in SMTP command or email header.
|
||||
///
|
||||
/// Return `None` if parsing fails.
|
||||
///
|
||||
/// Returns the first address if multiple are present.
|
||||
pub fn extract_address(input: &str) -> Option<String> {
|
||||
// TODO: at this point it's probably simpler to use regex ;p
|
||||
let input_lower = input.to_lowercase();
|
||||
let mut trimmed = input_lower
|
||||
.trim_start_matches("mail from:")
|
||||
.trim_start_matches("rcpt to:");
|
||||
trimmed = trimmed
|
||||
.split_once("=")
|
||||
.map(|(address_raw, _)| {
|
||||
address_raw
|
||||
.rsplit_once(' ')
|
||||
.map(|(addr, _)| addr)
|
||||
.unwrap_or(address_raw)
|
||||
.trim()
|
||||
})
|
||||
.unwrap_or(trimmed);
|
||||
|
||||
mailparse::addrparse(trimmed)
|
||||
.ok()
|
||||
.and_then(|addr| match addr.first() {
|
||||
Some(MailAddr::Single(single)) => Some(single.addr.clone()),
|
||||
Some(MailAddr::Group(group)) => group.addrs.first().map(|single| single.addr.clone()),
|
||||
None => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Formats SMTP error to be able to send it back to postfix.
|
||||
pub fn format_smtp_error(error: lettre::transport::smtp::Error) -> String {
|
||||
if let Some(code) = error.status() {
|
||||
format!(
|
||||
"{} {}",
|
||||
code,
|
||||
error
|
||||
.source()
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or("Unknown error".to_string())
|
||||
)
|
||||
} else {
|
||||
// Default to 451, most probably means some internal service error (e.g. milter)
|
||||
format!(
|
||||
"451 {}",
|
||||
error
|
||||
.source()
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or("Unknown error".to_string())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::*;
|
||||
|
||||
#[rstest]
|
||||
#[case("MAIL FROM:<t1@example.org>", Some("t1@example.org".to_string()))]
|
||||
#[case("MAIL FROM:<t2@example.org> SOMETHING=SOMETHING OTHER=OTHER", Some("t2@example.org".to_string()))]
|
||||
#[case("RCPT TO:<t3@example.org>", Some("t3@example.org".to_string()))]
|
||||
#[case("mail from:<t4@example.org>", Some("t4@example.org".to_string()))]
|
||||
#[case("Foo Bar <t5@example.org>", Some("t5@example.org".to_string()))]
|
||||
#[case("t6@example.org", Some("t6@example.org".to_string()))]
|
||||
fn test_extract_address(#[case] input: &str, #[case] expected: Option<String>) {
|
||||
let result = extract_address(input);
|
||||
assert_eq!(result, expected)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user