feat: Initial implementation

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
This commit is contained in:
Jagoda Ślązak
2026-01-14 18:03:07 +01:00
committed by Jagoda Estera Ślązak
parent 4185ebe3c5
commit 6340f479ae
21 changed files with 2870 additions and 0 deletions
+1315
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "filtermail"
version = "0.1.0"
edition = "2024"
[dependencies]
async-trait = "0.1.89"
base64 = "0.22.1"
serde = { version = "1.0.228", features = ["derive"] }
serini = "0.2.2"
tokio = { version = "1.49.0", features = ["full"] }
thiserror = "2.0.17"
mailparse = "0.16.1"
lettre = { version = "0.11.19", default-features = false, features = [
"smtp-transport",
"tokio1",
] }
log = "0.4.29"
env_logger = "0.11.8"
[dev-dependencies]
rstest = "0.26.1"
testresult = "0.4.1"
+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)
}
}
+56
View File
@@ -0,0 +1,56 @@
From: one@example.org
To: two@example.org
Autocrypt-Setup-Message: v1
Subject: Autocrypt Setup Message
Date: Tue, 22 Jan 2019 12:56:29 +0100
Content-type: multipart/mixed; boundary="Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ"
--Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ
Content-Type: text/plain
This message contains all information to transfer your Autocrypt
settings along with your secret key securely from your original
device.
To set up your new device for Autocrypt, please follow the
instuctions that should be presented by your new device.
You can keep this message and use it as a backup for your secret
key. If you want to do this, you should write down the Setup Code
and store it securely.
--Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ
Content-Type: application/autocrypt-setup
Content-Disposition: attachment; filename="autocrypt-setup-message.html"
<html><body>
<p>
This is the Autocrypt setup file used to transfer settings and
keys between clients. You can decrypt it using the Setup Code
presented on your old device, and then import the contained key
into your keyring.
</p>
<pre>
-----BEGIN PGP MESSAGE-----
Passphrase-Format: numeric9x4
Passphrase-Begin: 17
jA0EBwMCFAxADoCdzeX/0ukBlqI5+pfpKb751qd/7nLNbkpy3gVcaf1QwRPZYt40
Ynp08UqRQ2g48ZlnzHLSwlTGOPTuv2Jt8ka+pgZ45xzvJSG2gau03xP4VsC271kR
VmCjdb0Y6Rk96mAwfGzrkbaRQ9Z7fIoL866GOv6h9neiVIkp+JYlTV6ISD0ZQJ4Q
I6dOQkB/TWZyVjtiJDOQHdfNWliA6NtqaLq19wlu9L5xXjuNpY95KwR8EJXWe0+o
Y3d2U/KxOAkXKghP2Qg1GtlPVeGC5T4p03TGI6pzKT+kHX6Rrm9wK6sM9aTquMmF
Vok84Jg1DFnwivWC2RILR81rXi7k/+Y6MUbveFgJ9cQduqpxnmD7TjOblYu7M6zp
YGAUxh8DRKlIMn2QsA++DBYQ6ACZvwuY8qTDLkqPDo4WqM313dsMJbyGjDdVE7EM
PESS+RlABETpZXz8g/ycr6DIUNdlbPcmYlsBfHWDOuR2GFFTwmlv5slWS39dJv38
E0eIe1CwdxI801Se7t7dUUS/ZF8wb6GlmxOcqGbF8eko1Z0S64IAm7/h13MRQCxI
geQnHfGYVJ2FOimoCMEKwfa9x++RFTDW0u7spDC2uWvK/1viV8OfRppFhLr/kmKb
18lWXuAz80DAjUDUsVqEq2MvJBJGoCJUEyjuRsLkHYRM5jYk4v50LyyR0Om73nWF
nZBqmqNzdr7Xb9PHHdFhnEc0VvoYbrcM0RVYcEMW3YbmejM891j1d6Iv+/n/qND/
NdebGrfWJMmFLf/iEkzTZ3/v5inW9LpWoRc94ioCjJTaEo8Rib6ARRFaJVIsmNXi
YicFGO98D+zX+a2t9Yz6IpPajVslnOp6ScpmXgts/2XWD7oE+JgxSAqo/dLVsHgP
Ufo=
=pulM
-----END PGP MESSAGE-----
</pre></body></html>
--Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ--
+66
View File
@@ -0,0 +1,66 @@
From: one@example.org
To: two@example.org
Subject: {subject}
Date: Sun, 15 Oct 2023 16:43:21 +0000
Message-ID: <Mr.UVyJWZmkCKM.hGzNc6glBE_@c2.testrun.org>
In-Reply-To: <Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
<Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
Chat-Version: 1.0
Autocrypt: addr=one@example.org; prefer-encrypt=mutual;
keydata=xjMEZSwWjhYJKwYBBAHaRw8BAQdAQBEhqeJh0GueHB6kF/DUQqYCxARNBVokg/AzT+7LqH
rNFzxiYXJiYXpAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUsFo4CGwMECwkIBwYVCAkKCwID
FgIBFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX9A4AEAnHWHp49eBCMHK5t66gYPiW
XQuB1mwUjzGfYWB+0RXUoA/0xcQ3FbUNlGKW7Blp6eMFfViv6Mv2d3kNSXACB6nmcMzjgEZSwWjhIK
KwYBBAGXVQEFAQEHQBpY5L2M1XHo0uxf8SX1wNLBp/OVvidoWHQF2Jz+kJsUAwEIB8J4BBgWCAAgBQ
JlLBaOAhsMFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX/INgEA37AJaNvruYsJVanP
IXnYw4CKd55UAwl8Zcy+M2diAbkA/0fHHcGV4r78hpbbL1Os52DPOdqYQRauIeJUeG+G6bQO
MIME-Version: 1.0
Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";
boundary="YFrteb74qSXmggbOxZL9dRnhymywAi"
--YFrteb74qSXmggbOxZL9dRnhymywAi
Content-Description: PGP/MIME version identification
Content-Type: application/pgp-encrypted
Version: 1
--YFrteb74qSXmggbOxZL9dRnhymywAi
Content-Description: OpenPGP encrypted message
Content-Disposition: inline; filename="encrypted.asc";
Content-Type: application/octet-stream; name="encrypted.asc"
-----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-----
--YFrteb74qSXmggbOxZL9dRnhymywAi--
+25
View File
@@ -0,0 +1,25 @@
Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?=
Chat-Disposition-Notification-To: foobar@c2.testrun.org
Chat-User-Avatar: 0
From: <one@example.org>
To: <two@example.org>
Date: Sun, 15 Oct 2023 16:41:44 +0000
Message-ID: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
Chat-Version: 1.0
Autocrypt: addr=one@example.org; prefer-encrypt=mutual;
keydata=xjMEZSrw3hYJKwYBBAHaRw8BAQdAiEKNQFU28c6qsx4vo/JHdt73RXdjMOmByf/XsGiJ7m
nNFzxmb29iYXJAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUq8N4CGwMECwkIBwYVCAkKCwID
FgIBFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJCX3gEAhm0MehE5byBBU1avPczr/I
HjNLht7Qf6++mAhlJmtDcA/0C8VYJhsUpmiDjuZaMDWNv4FO2BJG6LH7gSm6n7ClMJzjgEZSrw3hIK
KwYBBAGXVQEFAQEHQAxGG/QW0owCfMp1A+vXEMwgzWcBpNFr58kX2eXuPpM6AwEIB8J4BBgWCAAgBQ
JlKvDeAhsMFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJDg1gEAwLf8KDoAAKyYgjyI
vYvO9VEgBni1C4Xx1VjcaEmlDK8BALoFuUCK+enw76TtDcAUKhlhUiM6SDRExkS4Nskp/BcK
MIME-Version: 1.0
Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
-----BEGIN PGP MESSAGE-----
Meow!
-----END PGP MESSAGE-----
+44
View File
@@ -0,0 +1,44 @@
From: one@example.org
To: two@example.org
Subject: ...
Date: Sun, 15 Oct 2023 16:43:21 +0000
Message-ID: <Mr.UVyJWZmkCKM.hGzNc6glBE_@c2.testrun.org>
In-Reply-To: <Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
<Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
Chat-Version: 1.0
Autocrypt: addr=one@example.org; prefer-encrypt=mutual;
keydata=xjMEZSwWjhYJKwYBBAHaRw8BAQdAQBEhqeJh0GueHB6kF/DUQqYCxARNBVokg/AzT+7LqH
rNFzxiYXJiYXpAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUsFo4CGwMECwkIBwYVCAkKCwID
FgIBFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX9A4AEAnHWHp49eBCMHK5t66gYPiW
XQuB1mwUjzGfYWB+0RXUoA/0xcQ3FbUNlGKW7Blp6eMFfViv6Mv2d3kNSXACB6nmcMzjgEZSwWjhIK
KwYBBAGXVQEFAQEHQBpY5L2M1XHo0uxf8SX1wNLBp/OVvidoWHQF2Jz+kJsUAwEIB8J4BBgWCAAgBQ
JlLBaOAhsMFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX/INgEA37AJaNvruYsJVanP
IXnYw4CKd55UAwl8Zcy+M2diAbkA/0fHHcGV4r78hpbbL1Os52DPOdqYQRauIeJUeG+G6bQO
MIME-Version: 1.0
Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";
boundary="YFrteb74qSXmggbOxZL9dRnhymywAi"
--YFrteb74qSXmggbOxZL9dRnhymywAi
Content-Description: PGP/MIME version identification
Content-Type: application/pgp-encrypted
Version: 1
--YFrteb74qSXmggbOxZL9dRnhymywAi
Content-Description: OpenPGP encrypted message
Content-Disposition: inline; filename="encrypted.asc";
Content-Type: application/octet-stream; name="encrypted.asc"
-----BEGIN PGP MESSAGE-----
yxJiAAAAAABIZWxsbyB3b3JsZCE=
=1I/B
-----END PGP MESSAGE-----
--YFrteb74qSXmggbOxZL9dRnhymywAi--
+46
View File
@@ -0,0 +1,46 @@
Date: Fri, 8 Jul 1994 09:21:47 -0400
From: Mail Delivery Subsystem <MAILER-DAEMON@example.org>
Subject: Returned mail: User unknown
To: <owner-ups-mib@CS.UTK.EDU>
Auto-Submitted: auto-replied
MIME-Version: 1.0
Content-Type: multipart/report; report-type=delivery-status;
boundary="JAA13167.773673707/CS.UTK.EDU"
--JAA13167.773673707/CS.UTK.EDU
content-type: text/plain; charset=us-ascii
----- The following addresses had delivery problems -----
<arathib@vnet.ibm.com> (unrecoverable error)
<wsnell@sdcc13.ucsd.edu> (unrecoverable error)
--JAA13167.773673707/CS.UTK.EDU
content-type: message/delivery-status
Reporting-MTA: dns; cs.utk.edu
Original-Recipient: rfc822;arathib@vnet.ibm.com
Final-Recipient: rfc822;arathib@vnet.ibm.com
Action: failed
Status: 5.0.0 (permanent failure)
Diagnostic-Code: smtp;
550 'arathib@vnet.IBM.COM' is not a registered gateway user
Remote-MTA: dns; vnet.ibm.com
Original-Recipient: rfc822;johnh@hpnjld.njd.hp.com
Final-Recipient: rfc822;johnh@hpnjld.njd.hp.com
Action: delayed
Status: 4.0.0 (hpnjld.njd.jp.com: host name lookup failure)
Original-Recipient: rfc822;wsnell@sdcc13.ucsd.edu
Final-Recipient: rfc822;wsnell@sdcc13.ucsd.edu
Action: failed
Status: 5.0.0
Diagnostic-Code: smtp; 550 user unknown
Remote-MTA: dns; sdcc13.ucsd.edu
--JAA13167.773673707/CS.UTK.EDU
content-type: message/rfc822
[original message goes here]
--JAA13167.773673707/CS.UTK.EDU--
+33
View File
@@ -0,0 +1,33 @@
Subject: Message opened
From: <one@example.org>
To: <two@example.org>
Date: Sun, 15 Oct 2023 16:43:25 +0000
Message-ID: <Mr.78MWtlV7RAi.goCFzBhCYfy@c2.testrun.org>
Auto-Submitted: auto-replied
Chat-Version: 1.0
MIME-Version: 1.0
Content-Type: multipart/report; report-type=disposition-notification;
boundary="Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi"
--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi
Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
The "Hi!" message you sent was displayed on the screen of the recipient.
This is no guarantee the content was read.
--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi
Content-Type: message/disposition-notification
Reporting-UA: Delta Chat 1.124.1
Original-Recipient: rfc822;barbaz@c2.testrun.org
Final-Recipient: rfc822;barbaz@c2.testrun.org
Original-Message-ID: <Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
Disposition: manual-action/MDN-sent-automatically; displayed
--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi--
+23
View File
@@ -0,0 +1,23 @@
Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?=
Chat-Disposition-Notification-To: foobar@c2.testrun.org
Chat-User-Avatar: 0
From: <one@example.org>
To: <two@example.org>
Date: Sun, 15 Oct 2023 16:41:44 +0000
Message-ID: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
Chat-Version: 1.0
Autocrypt: addr=one@example.org; prefer-encrypt=mutual;
keydata=xjMEZSrw3hYJKwYBBAHaRw8BAQdAiEKNQFU28c6qsx4vo/JHdt73RXdjMOmByf/XsGiJ7m
nNFzxmb29iYXJAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUq8N4CGwMECwkIBwYVCAkKCwID
FgIBFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJCX3gEAhm0MehE5byBBU1avPczr/I
HjNLht7Qf6++mAhlJmtDcA/0C8VYJhsUpmiDjuZaMDWNv4FO2BJG6LH7gSm6n7ClMJzjgEZSrw3hIK
KwYBBAGXVQEFAQEHQAxGG/QW0owCfMp1A+vXEMwgzWcBpNFr58kX2eXuPpM6AwEIB8J4BBgWCAAgBQ
JlKvDeAhsMFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJDg1gEAwLf8KDoAAKyYgjyI
vYvO9VEgBni1C4Xx1VjcaEmlDK8BALoFuUCK+enw76TtDcAUKhlhUiM6SDRExkS4Nskp/BcK
MIME-Version: 1.0
Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
Meow!
@@ -0,0 +1,21 @@
Subject: Message from one@example.org
From: <one@example.org>
To: <two@example.org>
Date: Sun, 15 Oct 2023 16:43:25 +0000
Message-ID: <Mr.78MWtlV7RAi.goCFzBhCYfy@c2.testrun.org>
Chat-Version: 1.0
Secure-Join: vc-request
Secure-Join-Invitenumber: RANDOM-TOKEN
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi"
--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi
Content-Type: text/plain; charset=utf-8
Meow!
--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi--
+21
View File
@@ -0,0 +1,21 @@
Subject: Message from one@example.org
From: <one@example.org>
To: <two@example.org>
Date: Sun, 15 Oct 2023 16:43:25 +0000
Message-ID: <Mr.78MWtlV7RAi.goCFzBhCYfy@c2.testrun.org>
Chat-Version: 1.0
Secure-Join: vc-request
Secure-Join-Invitenumber: RANDOM-TOKEN
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi"
--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi
Content-Type: text/plain; charset=utf-8
Secure-Join: vc-request
--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi--