feat: Initial implementation

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
This commit is contained in:
Jagoda Ślązak
2026-01-19 16:49:50 +01:00
committed by Jagoda Estera Ślązak
parent 4185ebe3c5
commit 6340f479ae
21 changed files with 2870 additions and 0 deletions
+73
View File
@@ -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()
}
}
+13
View File
@@ -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,
}
+113
View File
@@ -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(())
}
}
+75
View File
@@ -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);
}
}
}
+204
View File
@@ -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);
}
}
+269
View File
@@ -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);
}
}
+145
View File
@@ -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(())
}
}
+35
View File
@@ -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
}
}
}
+196
View File
@@ -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(())
}
+74
View File
@@ -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)
}
}