mirror of
https://github.com/chatmail/relay.git
synced 2026-08-10 10:30:51 +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
Generated
+1315
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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--
|
||||
@@ -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--
|
||||
|
||||
|
||||
@@ -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-----
|
||||
|
||||
|
||||
@@ -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--
|
||||
|
||||
|
||||
@@ -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--
|
||||
@@ -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--
|
||||
|
||||
|
||||
@@ -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--
|
||||
|
||||
|
||||
@@ -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--
|
||||
|
||||
|
||||
Reference in New Issue
Block a user