mirror of
https://github.com/chatmail/relay.git
synced 2026-09-16 20:30:09 +00:00
feat: Initial implementation
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
This commit is contained in:
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