feat(smtp): implement built-in SMTP server for mail ingestion

- Add lightweight SMTP server support using `lettre` and `tokio`.
- Implement `DATA_SMTP_INGEST` permission check for inbound mail.
- Support real-time email archiving via SMTP protocol.
- Integrate with existing EML index manager for automated indexing.
This commit is contained in:
rustmailer
2026-03-10 01:25:02 +08:00
parent 16f0fad91e
commit 4b0d571cf2
38 changed files with 1609 additions and 32 deletions
+11
View File
@@ -421,6 +421,17 @@ impl AccountV4 {
list_all_impl(DB_MANAGER.meta_db()).await
}
pub async fn find_by_email(email: &str) -> BichonResult<Option<AccountModel>> {
let all: Vec<AccountModel> = list_all_impl(DB_MANAGER.meta_db()).await?;
let target_email = email.trim().to_lowercase();
let first_match = all
.into_iter()
.find(|acc| acc.email.to_lowercase() == target_email);
Ok(first_match)
}
pub async fn minimal_list(only_nosync: bool) -> BichonResult<Vec<MinimalAccount>> {
let result = list_all_impl(DB_MANAGER.meta_db())
.await?
+35 -4
View File
@@ -93,6 +93,37 @@ impl ClientContext {
))
}
pub async fn check_has_permission(user: &UserModel, account_id: Option<u64>, permission: &str) -> bool {
if user.is_admin().await {
return true;
}
let mut global_perms = HashSet::new();
for rid in &user.global_roles {
if let Some(role) = UserRole::find(*rid).await.ok().flatten() {
global_perms.extend(role.permissions);
}
}
if Self::check_global_logic(&global_perms, permission) {
return true;
}
if let Some(aid) = account_id {
if let Some(role_id) = user.account_access_map.get(&aid) {
if let Some(role) = UserRole::find(*role_id).await.ok().flatten() {
if role.permissions.contains(&permission.to_string())
|| Self::check_account_logic(&role.permissions, permission)
{
return true;
}
}
}
}
false
}
pub async fn has_permission(&self, account_id: Option<u64>, permission: &str) -> bool {
if self.user.is_admin().await {
return true;
@@ -105,7 +136,7 @@ impl ClientContext {
}
}
if self.check_global_logic(&global_perms, permission) {
if Self::check_global_logic(&global_perms, permission) {
return true;
}
@@ -113,7 +144,7 @@ impl ClientContext {
if let Some(role_id) = self.user.account_access_map.get(&aid) {
if let Some(role) = UserRole::find(*role_id).await.ok().flatten() {
if role.permissions.contains(&permission.to_string())
|| self.check_account_logic(&role.permissions, permission)
|| Self::check_account_logic(&role.permissions, permission)
{
return true;
}
@@ -124,7 +155,7 @@ impl ClientContext {
false
}
fn check_global_logic(&self, global: &HashSet<String>, perm: &str) -> bool {
fn check_global_logic(global: &HashSet<String>, perm: &str) -> bool {
if global.contains(perm) {
return true;
}
@@ -141,7 +172,7 @@ impl ClientContext {
}
}
fn check_account_logic(&self, scoped_perms: &BTreeSet<String>, perm: &str) -> bool {
fn check_account_logic(scoped_perms: &BTreeSet<String>, perm: &str) -> bool {
if scoped_perms.contains(perm) {
return true;
}
+15
View File
@@ -61,6 +61,21 @@ pub fn extract_envelope_from_eml(
)
}
pub fn extract_envelope_from_smtp(
body: &[u8],
account_id: u64,
mailbox_id: u64,
) -> BichonResult<(Envelope, Vec<AttachmentInfo>)> {
extract_envelope_core(
body,
0,
body.len() as u32,
utc_now!(),
account_id,
mailbox_id,
)
}
fn extract_envelope_core(
body: &[u8],
uid: u32,
+1
View File
@@ -36,6 +36,7 @@ pub mod message;
pub mod oauth2;
pub mod rest;
pub mod settings;
pub mod smtp;
pub mod tasks;
pub mod token;
pub mod users;
+7 -2
View File
@@ -18,13 +18,14 @@
use crate::modules::common::error::ErrorCapture;
use crate::modules::common::log::Tracing;
use crate::modules::common::signal::SIGNAL_MANAGER;
use crate::modules::common::tls::rustls_config;
use crate::modules::error::code::ErrorCode;
use crate::modules::error::handler::error_handler;
use crate::modules::error::BichonResult;
use crate::modules::rest::public::login::login;
use crate::modules::rest::public::status::get_status;
use crate::modules::{settings::cli::SETTINGS, utils::shutdown::shutdown_signal};
use crate::modules::settings::cli::SETTINGS;
use super::error::ApiErrorResponse;
use crate::modules::common::auth::ApiGuard;
@@ -137,12 +138,16 @@ pub async fn start_http_server() -> BichonResult<()> {
.with_if(SETTINGS.bichon_http_compression_enabled, Compression::new())
.with(CatchPanic::new());
let mut rx = SIGNAL_MANAGER.subscribe();
let shutdown_fut = async move {
let _ = rx.recv().await;
};
let server = Server::new(listener)
.name("Bichon Service")
.idle_timeout(Duration::from_secs(60))
.run_with_graceful_shutdown(
route.catch_all_error(error_handler),
shutdown_signal(),
shutdown_fut,
Some(Duration::from_secs(5)),
);
println!(
+87
View File
@@ -300,6 +300,73 @@ pub struct Settings {
help = "Set the Tantivy docstore block size in bytes (default: 2MB)"
)]
pub bichon_eml_blocksize: usize,
#[clap(
long,
env,
default_value = "false",
help = "Enable the embedded SMTP server for real-time email receiving"
)]
pub bichon_enable_smtp: bool,
#[clap(
long,
env,
help = "Path to the SMTP TLS private key file (e.g., key.pem)",
value_parser = ValueParser::new(|s: &str| {
let path = PathBuf::from(s);
if !path.is_absolute() {
return Err("'bichon_smtp_tls_key_path' must be an absolute path".to_string());
}
if !path.exists() {
return Err(format!("SMTP TLS key file not found: {}", s));
}
Ok(s.to_string())
})
)]
pub bichon_smtp_tls_key_path: Option<String>,
#[clap(
long,
env,
help = "Path to the SMTP TLS certificate chain file (e.g., cert.pem)",
value_parser = ValueParser::new(|s: &str| {
let path = PathBuf::from(s);
if !path.is_absolute() {
return Err("'bichon_smtp_tls_cert_path' must be an absolute path".to_string());
}
if !path.exists() {
return Err(format!("SMTP TLS certificate file not found: {}", s));
}
Ok(s.to_string())
})
)]
pub bichon_smtp_tls_cert_path: Option<String>,
#[clap(
long,
default_value = "2525",
env,
help = "Set the SMTP port for Bichon (e.g., 25 or 2525). Note: Port 25 may require root privileges.",
value_parser = clap::value_parser!(u16).range(1..)
)]
pub bichon_smtp_port: u16,
#[clap(
long,
env,
default_value = "starttls",
help = "Set the encryption mode for SMTP: 'none', 'starttls', or 'tls'"
)]
pub bichon_smtp_encryption: SmtpEncryptionMode,
#[clap(
long,
env,
default_value = "true",
help = "Enable SMTP authentication requirement"
)]
pub bichon_smtp_auth_required: bool,
}
impl Settings {
@@ -339,3 +406,23 @@ impl fmt::Display for CompressionAlgorithm {
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, ValueEnum)]
pub enum SmtpEncryptionMode {
#[clap(name = "none")]
None,
#[clap(name = "starttls")]
Starttls,
#[clap(name = "tls")]
Tls,
}
impl fmt::Display for SmtpEncryptionMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SmtpEncryptionMode::None => write!(f, "none"),
SmtpEncryptionMode::Starttls => write!(f, "starttls"),
SmtpEncryptionMode::Tls => write!(f, "tls"),
}
}
}
+109
View File
@@ -0,0 +1,109 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::net::SocketAddr;
use tokio::net::TcpListener;
use tokio_rustls::TlsAcceptor;
use crate::modules::{
common::signal::SIGNAL_MANAGER,
settings::cli::{SmtpEncryptionMode, SETTINGS},
smtp::{
server::{run_smtp_server, run_smtps_server},
tls::create_acceptor,
},
};
pub mod server;
pub mod stream;
pub mod tls;
#[cfg(test)]
mod tests;
#[derive(Clone, Default)]
pub struct SmtpConfig {
pub whitelist: Option<Vec<String>>,
pub tls_acceptor: Option<TlsAcceptor>,
pub auth_required: bool,
}
pub struct SmtpServer {
pub smtp_addr: SocketAddr,
smtp_handle: tokio::task::JoinHandle<()>,
}
impl SmtpServer {
pub async fn stop(self) {
let _ = self.smtp_handle.await;
}
}
pub async fn start_smtp_server() -> std::io::Result<SmtpServer> {
let smtp_port = SETTINGS.bichon_smtp_port;
let tls_acceptor: Option<TlsAcceptor> = match SETTINGS.bichon_smtp_encryption {
SmtpEncryptionMode::None => None,
SmtpEncryptionMode::Starttls | SmtpEncryptionMode::Tls => Some(create_acceptor().await?),
};
let smtp_listener = TcpListener::bind((
SETTINGS.bichon_bind_ip.clone().unwrap_or("0.0.0.0".into()),
smtp_port,
))
.await
.map_err(|e| {
if e.kind() == std::io::ErrorKind::AddrInUse {
std::io::Error::other(format!(
"SMTP port {smtp_port} is already in use. Is another instance running?"
))
} else {
e
}
})?;
let smtp_addr = smtp_listener.local_addr()?;
let smtp_config = SmtpConfig {
whitelist: None,
tls_acceptor: match SETTINGS.bichon_smtp_encryption {
SmtpEncryptionMode::None | SmtpEncryptionMode::Tls => None,
SmtpEncryptionMode::Starttls => tls_acceptor.clone(),
},
auth_required: SETTINGS.bichon_smtp_auth_required,
};
let smtp_shutdown = SIGNAL_MANAGER.subscribe();
let smtp_handle = if matches!(SETTINGS.bichon_smtp_encryption, SmtpEncryptionMode::Tls) {
let acceptor = tls_acceptor
.clone()
.expect("TLS acceptor required when tls=true");
tokio::spawn(async move {
run_smtps_server(smtp_listener, smtp_config, acceptor, smtp_shutdown).await;
})
} else {
tokio::spawn(async move {
run_smtp_server(smtp_listener, smtp_config, smtp_shutdown).await;
})
};
Ok(SmtpServer {
smtp_addr,
smtp_handle,
})
}
+667
View File
@@ -0,0 +1,667 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::io;
use std::time::Duration;
use crate::modules::cache::imap::mailbox::{Attribute, AttributeEnum};
use crate::modules::envelope::extractor::extract_envelope_from_smtp;
use crate::modules::error::BichonResult;
use crate::modules::indexer::manager::EML_INDEX_MANAGER;
use crate::modules::indexer::manager::ENVELOPE_INDEX_MANAGER;
use crate::modules::indexer::schema::SchemaTools;
use crate::modules::utils::create_hash;
use crate::modules::{
account::migration::AccountModel,
cache::imap::mailbox::MailBox,
common::auth::ClientContext,
smtp::{stream::BufStream, SmtpConfig},
token::AccessTokenModel,
users::{permissions::Permission, UserModel},
};
use base64::{prelude::BASE64_STANDARD, Engine as _};
use tantivy::doc;
use tokio::time::timeout;
use tokio::{
io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt},
net::{TcpListener, TcpStream},
sync::broadcast,
};
use tokio_rustls::TlsAcceptor;
const MAX_MAIL_SIZE: usize = 50 * 1024 * 1024; //50MB
const SMTP_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
const GLOBAL_SESSION_TIMEOUT: Duration = Duration::from_secs(600);
pub async fn run_smtp_server(
listener: TcpListener,
config: SmtpConfig,
mut shutdown: broadcast::Receiver<()>,
) {
loop {
tokio::select! {
result = listener.accept() => {
match result {
Ok((stream, addr)) => {
tracing::debug!("SMTP connection from {addr}");
let config = config.clone();
tokio::spawn(async move {
let res = timeout(GLOBAL_SESSION_TIMEOUT, handle_connection(stream, config)).await;
match res {
Ok(Ok(_)) => tracing::debug!("SMTP session from {addr} finished"),
Ok(Err(e)) => tracing::debug!("SMTP session error from {addr}: {e}"),
Err(_) => tracing::warn!("SMTP session from {addr} timed out after {}s", GLOBAL_SESSION_TIMEOUT.as_secs()),
}
});
}
Err(e) => {
tracing::error!("Failed to accept connection: {e}");
}
}
}
_ = shutdown.recv() => {
break;
}
}
}
}
pub async fn run_smtps_server(
listener: TcpListener,
config: SmtpConfig,
tls_acceptor: TlsAcceptor,
mut shutdown: broadcast::Receiver<()>,
) {
loop {
tokio::select! {
result = listener.accept() => {
match result {
Ok((stream, addr)) => {
tracing::debug!("SMTPS connection from {addr}");
let config = config.clone();
let acceptor = tls_acceptor.clone();
tokio::spawn(async move {
match acceptor.accept(stream).await {
Ok(tls_stream) => {
let res = timeout(GLOBAL_SESSION_TIMEOUT, handle_tls_connection(tls_stream, config)).await;
match res {
Ok(Ok(_)) => tracing::debug!("SMTPS session from {addr} finished"),
Ok(Err(e)) => tracing::debug!("SMTPS session error from {addr}: {e}"),
Err(_) => tracing::warn!("SMTPS session from {addr} timed out after {}s", GLOBAL_SESSION_TIMEOUT.as_secs()),
}
}
Err(e) => {
tracing::debug!("TLS handshake failed: {e}");
}
}
});
}
Err(e) => {
tracing::error!("Failed to accept connection: {e}");
}
}
}
_ = shutdown.recv() => {
break;
}
}
}
}
enum CommandResult {
Continue,
Quit,
StartTls,
}
struct Session {
mail_from: Option<String>,
rcpt_to: Vec<AccountModel>,
authenticated: bool,
user: Option<UserModel>,
auth_required: bool,
tls_active: bool,
auth_state: AuthState,
}
impl Session {
const fn new(auth_required: bool, tls_active: bool) -> Self {
Self {
mail_from: None,
rcpt_to: Vec::new(),
authenticated: false,
user: None,
auth_required,
tls_active,
auth_state: AuthState::None,
}
}
fn reset(&mut self) {
self.mail_from = None;
self.rcpt_to.clear();
}
}
#[derive(Default)]
enum AuthState {
#[default]
None,
WaitingForPlain,
WaitingForLoginUsername,
WaitingForLoginPassword(String),
}
/// Handle a plain TCP connection with optional STARTTLS upgrade.
async fn handle_connection(stream: TcpStream, config: SmtpConfig) -> io::Result<()> {
let mut session = Session::new(config.auth_required, false);
// Use buffered I/O over the raw stream
let mut stream = BufStream::new(stream);
stream
.write_all(b"220 localhost ESMTP (Bichon Email Archiver)\r\n")
.await?;
stream.flush().await?;
loop {
match process_command(&mut stream, &mut session, &config).await? {
CommandResult::Continue => {}
CommandResult::Quit => break,
CommandResult::StartTls => {
if let Some(ref acceptor) = config.tls_acceptor {
tracing::debug!("Upgrading connection to TLS");
let inner = stream.into_inner();
match acceptor.clone().accept(inner).await {
Ok(tls_stream) => {
session.tls_active = true;
session.reset();
return handle_tls_session(tls_stream, session, config).await;
}
Err(e) => {
tracing::debug!("STARTTLS handshake failed: {e}");
return Err(io::Error::other(format!("TLS handshake failed: {e}")));
}
}
}
}
}
}
Ok(())
}
async fn handle_tls_connection<S>(stream: S, config: SmtpConfig) -> io::Result<()>
where
S: AsyncRead + AsyncWrite + Unpin,
{
let session = Session::new(config.auth_required, true);
handle_tls_session(stream, session, config).await
}
async fn handle_tls_session<S>(
stream: S,
mut session: Session,
config: SmtpConfig,
) -> io::Result<()>
where
S: AsyncRead + AsyncWrite + Unpin,
{
let mut stream = BufStream::new(stream);
loop {
match process_command(&mut stream, &mut session, &config).await? {
CommandResult::Continue => {}
CommandResult::Quit => break,
CommandResult::StartTls => {
stream.write_all(b"503 TLS already active\r\n").await?;
stream.flush().await?;
}
}
}
Ok(())
}
#[allow(clippy::too_many_lines)]
async fn process_command<S>(
stream: &mut BufStream<S>,
session: &mut Session,
config: &SmtpConfig,
) -> io::Result<CommandResult>
where
S: AsyncRead + AsyncWrite + Unpin,
{
let mut line = String::new();
let bytes_read = match timeout(SMTP_IDLE_TIMEOUT, stream.inner.read_line(&mut line)).await {
Ok(res) => res?,
Err(_) => return Err(io::Error::new(io::ErrorKind::TimedOut, "Command timeout")),
};
if bytes_read == 0 {
return Ok(CommandResult::Quit);
}
let trimmed = line.trim();
let cmd = trimmed.to_uppercase();
match &session.auth_state {
AuthState::WaitingForPlain => {
verify_plain_auth(trimmed, session, stream).await?;
session.auth_state = AuthState::None;
return Ok(CommandResult::Continue);
}
AuthState::WaitingForLoginUsername => {
if let Ok(decoded) = BASE64_STANDARD.decode(trimmed) {
let username = String::from_utf8_lossy(&decoded).to_string();
stream.write_all(b"334 UGFzc3dvcmQ6\r\n").await?;
stream.flush().await?;
session.auth_state = AuthState::WaitingForLoginPassword(username);
} else {
stream.write_all(b"501 Cannot decode\r\n").await?;
stream.flush().await?;
session.auth_state = AuthState::None;
}
return Ok(CommandResult::Continue);
}
AuthState::WaitingForLoginPassword(username) => {
let username = username.clone();
if let Ok(decoded) = BASE64_STANDARD.decode(trimmed) {
let password = String::from_utf8_lossy(&decoded);
match AccessTokenModel::resolve_user_from_token(&password).await {
Ok(user) => {
session.authenticated = true;
session.user = Some(user);
stream
.write_all(b"235 Authentication successful\r\n")
.await?;
}
Err(error) => {
tracing::error!(
"SMTP Auth failed for user '{}' (AUTH LOGIN): {:?}",
username,
error
);
stream.write_all(b"535 Authentication failed\r\n").await?;
}
}
} else {
stream.write_all(b"501 Cannot decode\r\n").await?;
}
stream.flush().await?;
session.auth_state = AuthState::None;
return Ok(CommandResult::Continue);
}
AuthState::None => {}
}
if cmd.starts_with("EHLO") || cmd.starts_with("HELO") {
let mut response = String::from("250-Bichon Hello\r\n");
response.push_str("250-SIZE 52428800\r\n"); // 50MB
response.push_str("250-8BITMIME\r\n");
if config.tls_acceptor.is_some() && !session.tls_active {
response.push_str("250-STARTTLS\r\n");
}
response.push_str("250-AUTH PLAIN LOGIN\r\n");
response.push_str("250 OK\r\n");
stream.write_all(response.as_bytes()).await?;
stream.flush().await?;
} else if cmd.starts_with("STARTTLS") {
if config.tls_acceptor.is_none() {
stream.write_all(b"454 TLS not available\r\n").await?;
} else if session.tls_active {
stream.write_all(b"503 TLS already active\r\n").await?;
} else {
stream.write_all(b"220 Ready to start TLS\r\n").await?;
stream.flush().await?;
return Ok(CommandResult::StartTls);
}
} else if cmd.starts_with("AUTH ") {
let parts: Vec<&str> = trimmed.split_whitespace().collect();
if parts.len() >= 2 {
let mechanism = parts[1].to_uppercase();
match mechanism.as_str() {
"PLAIN" => {
if parts.len() > 2 {
verify_plain_auth(parts[2], session, stream).await?;
} else {
stream.write_all(b"334 \r\n").await?;
session.auth_state = AuthState::WaitingForPlain;
}
}
"LOGIN" => {
stream.write_all(b"334 VXNlcm5hbWU6\r\n").await?;
session.auth_state = AuthState::WaitingForLoginUsername;
}
_ => {
stream.write_all(b"504 Unrecognized auth type\r\n").await?;
}
}
} else {
stream.write_all(b"501 Syntax error\r\n").await?;
}
} else if cmd.starts_with("MAIL FROM:") {
if session.auth_required && !session.authenticated {
stream.write_all(b"530 Authentication required\r\n").await?;
} else {
let addr = extract_address(&trimmed[10..]);
let mut allowed = true;
if let Some(ref whitelist) = config.whitelist {
if !whitelist.is_empty() && !whitelist.contains(&addr) {
allowed = false;
}
}
if allowed {
session.mail_from = Some(addr);
stream.write_all(b"250 OK\r\n").await?;
} else {
stream.write_all(b"550 Sender not allowed\r\n").await?;
}
}
} else if cmd.starts_with("RCPT TO:") {
if !session.rcpt_to.is_empty() {
stream
.write_all(b"452 4.5.3 Too many recipients, try again in a new transaction\r\n")
.await?;
return Ok(CommandResult::Continue);
}
if session.auth_required && !session.authenticated {
stream.write_all(b"530 Authentication required\r\n").await?;
} else if session.mail_from.is_none() {
stream
.write_all(b"503 MAIL FROM required first\r\n")
.await?;
} else {
let addr = extract_address(&trimmed[8..]);
println!("DEBUG: SMTP RCPT TO extracted address -> '{}'", addr);
let account_result = AccountModel::find_by_email(addr.as_str()).await;
match account_result {
Ok(Some(account)) => {
let mut is_allowed = true;
if session.auth_required {
if let Some(user) = &session.user {
let has_perm = ClientContext::check_has_permission(
user,
Some(account.id),
Permission::DATA_SMTP_INGEST,
)
.await;
if !has_perm {
tracing::warn!(
"SMTP: Access denied for User {} to Account <{}>",
user.id,
addr
);
stream
.write_all(
b"554 5.7.1 Access denied: Insufficient permissions\r\n",
)
.await?;
is_allowed = false;
}
} else {
stream
.write_all(b"530 5.7.0 Authentication required\r\n")
.await?;
is_allowed = false;
}
}
if is_allowed {
session.rcpt_to.push(account);
stream.write_all(b"250 OK\r\n").await?;
}
}
Ok(None) => {
let err = format!("550 5.1.1 <{}>: Bichon account not found\r\n", addr);
stream.write_all(err.as_bytes()).await?;
}
Err(e) => {
tracing::error!("SMTP: Account query error for {}: {:?}", addr, e);
stream
.write_all(
b"451 4.3.0 Requested action aborted: local error in processing\r\n",
)
.await?;
}
}
}
} else if cmd == "DATA" {
if session.auth_required && !session.authenticated {
stream.write_all(b"530 Authentication required\r\n").await?;
} else if session.mail_from.is_none() {
stream
.write_all(b"503 MAIL FROM required first\r\n")
.await?;
} else if session.rcpt_to.is_empty() {
stream.write_all(b"503 RCPT TO required first\r\n").await?;
} else {
stream
.write_all(b"354 End data with <CR><LF>.<CR><LF>\r\n")
.await?;
stream.flush().await?;
let data = match read_data(&mut stream.inner).await {
Ok(d) => d,
Err(e) => {
if e.to_string().contains("552") {
let error_msg = format!(
"552 5.3.4 Message size exceeds limit of {} bytes ({}MB)\r\n",
MAX_MAIL_SIZE,
MAX_MAIL_SIZE / 1024 / 1024
);
stream.write_all(error_msg.as_bytes()).await?;
stream.flush().await?;
return Ok(CommandResult::Continue);
}
return Err(e);
}
};
match parse_email(&data, session).await {
Ok(_) => {
stream
.write_all(b"250 2.0.0 OK: queued in Bichon\r\n")
.await?;
tracing::info!(
"SMTP: Message accepted and archived for {} recipients",
session.rcpt_to.len()
);
session.reset();
}
Err(e) => {
tracing::error!("SMTP: Critical error during parse_email: {:?}", e);
stream
.write_all(
b"451 4.3.0 Error: local error in processing, try again later\r\n",
)
.await?;
}
}
}
} else if cmd == "RSET" {
session.reset();
stream.write_all(b"250 OK\r\n").await?;
} else if cmd == "NOOP" {
stream.write_all(b"250 OK\r\n").await?;
} else if cmd == "QUIT" {
stream.write_all(b"221 Bye\r\n").await?;
stream.flush().await?;
return Ok(CommandResult::Quit);
} else {
stream.write_all(b"500 Command not recognized\r\n").await?;
}
stream.flush().await?;
Ok(CommandResult::Continue)
}
async fn verify_plain_auth<S: AsyncRead + AsyncWrite + Unpin>(
encoded: &str,
session: &mut Session,
stream: &mut BufStream<S>,
) -> io::Result<()> {
if let Ok(decoded) = BASE64_STANDARD.decode(encoded.trim()) {
let parts: Vec<&[u8]> = decoded.split(|&b| b == 0).collect();
if parts.len() >= 3 {
let username = String::from_utf8_lossy(parts[1]);
let password = String::from_utf8_lossy(parts[2]);
match AccessTokenModel::resolve_user_from_token(&password).await {
Ok(user) => {
session.authenticated = true;
session.user = Some(user);
stream
.write_all(b"235 Authentication successful\r\n")
.await?;
stream.flush().await?;
return Ok(());
}
Err(error) => {
tracing::error!("SMTP Auth failed for user '{}': {:?}", username, error);
}
}
}
}
stream.write_all(b"535 Authentication failed\r\n").await?;
stream.flush().await?;
Ok(())
}
fn extract_address(s: &str) -> String {
let s = s.trim();
if let (Some(start), Some(end)) = (s.find('<'), s.find('>')) {
return s[start + 1..end].to_string();
}
s.to_string()
}
async fn read_data<R: AsyncBufReadExt + Unpin>(reader: &mut R) -> io::Result<Vec<u8>> {
let mut data = Vec::with_capacity(65536);
let mut line = String::new();
let mut total_bytes = 0;
let line_timeout = Duration::from_secs(30);
loop {
line.clear();
let bytes_read = match timeout(line_timeout, reader.read_line(&mut line)).await {
Ok(res) => res?,
Err(_) => {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"Data transmission timeout",
))
}
};
if bytes_read == 0 {
break;
}
total_bytes += bytes_read;
if total_bytes > MAX_MAIL_SIZE {
tracing::warn!(
"SMTP: Message rejected. Size {} bytes exceeds limit of {}MB",
total_bytes,
MAX_MAIL_SIZE / 1024 / 1024
);
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"552 5.3.4 Message size exceeds fixed maximum message size",
));
}
if line.trim() == "." {
break;
}
let content = if line.starts_with("..") {
&line[1..]
} else {
&line
};
data.extend_from_slice(content.as_bytes());
}
Ok(data)
}
async fn parse_email(data: &[u8], session: &Session) -> BichonResult<()> {
let fields = SchemaTools::eml_fields();
let rcpt = match session.rcpt_to.first() {
Some(r) => r,
None => {
tracing::warn!(
"SMTP: parse_email called with empty recipient list. Skipping processing."
);
return Ok(());
}
};
let mailbox = MailBox {
id: create_hash(rcpt.id, "INBOX"),
account_id: rcpt.id,
name: "INBOX".into(),
delimiter: Some("/".to_string()),
attributes: vec![Attribute {
attr: AttributeEnum::Extension,
extension: Some("CreatedByBichon".into()),
}],
exists: 0,
unseen: None,
uid_next: None,
uid_validity: None,
};
let mailbox_id = mailbox.id;
if let Err(e) = MailBox::batch_upsert(&[mailbox]).await {
tracing::error!("SMTP: Failed to upsert mailbox for {}: {:?}", rcpt.email, e);
return Err(e.into());
}
let envelope = extract_envelope_from_smtp(data, rcpt.id, mailbox_id).map_err(|e| {
tracing::error!(
"SMTP: Envelope extraction failed for {}: {:?}",
rcpt.email,
e
);
e
})?;
let eml_id = create_hash(rcpt.id, &envelope.0.message_id);
ENVELOPE_INDEX_MANAGER
.add_document(envelope.0.id, envelope)
.await;
EML_INDEX_MANAGER
.add_document(
eml_id,
doc!(
fields.f_id => eml_id,
fields.f_account_id => rcpt.id,
fields.f_mailbox_id => mailbox_id,
fields.f_eml => data
),
)
.await;
Ok(())
}
+83
View File
@@ -0,0 +1,83 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::{io, pin::Pin};
use tokio::io::{AsyncBufRead, AsyncRead, AsyncWrite, BufReader};
pub struct BufStream<S> {
pub inner: BufReader<S>,
}
impl<S: AsyncRead + AsyncWrite + Unpin> BufStream<S> {
pub fn new(stream: S) -> Self {
Self {
inner: BufReader::new(stream),
}
}
pub fn into_inner(self) -> S {
self.inner.into_inner()
}
}
impl<S: AsyncRead + Unpin> AsyncBufRead for BufStream<S> {
fn poll_fill_buf(
self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<io::Result<&[u8]>> {
Pin::new(&mut self.get_mut().inner).poll_fill_buf(cx)
}
fn consume(self: Pin<&mut Self>, amt: usize) {
Pin::new(&mut self.get_mut().inner).consume(amt);
}
}
impl<S: AsyncRead + Unpin> AsyncRead for BufStream<S> {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<io::Result<()>> {
Pin::new(&mut self.get_mut().inner).poll_read(cx, buf)
}
}
impl<S: AsyncRead + AsyncWrite + Unpin> AsyncWrite for BufStream<S> {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<io::Result<usize>> {
Pin::new(self.get_mut().inner.get_mut()).poll_write(cx, buf)
}
fn poll_flush(
self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<io::Result<()>> {
Pin::new(self.get_mut().inner.get_mut()).poll_flush(cx)
}
fn poll_shutdown(
self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<io::Result<()>> {
Pin::new(self.get_mut().inner.get_mut()).poll_shutdown(cx)
}
}
+91
View File
@@ -0,0 +1,91 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::error::Error;
use lettre::address::Envelope;
use lettre::transport::smtp::authentication::{Credentials, Mechanism};
use lettre::transport::smtp::client::{Tls, TlsParameters};
use lettre::{Message, SmtpTransport, Transport};
#[tokio::test]
async fn test_smtp_archiving_flow() {
let email = Message::builder()
.from("tester@bichon.local".parse().unwrap())
.to("archive@bichon.local".parse().unwrap())
.subject("Integration Test")
.body(String::from("Checking if Bichon saves this!"))
.unwrap();
let envelope = Envelope::new(
Some("sender@example.com".parse().unwrap()),
vec!["placeholder@example.com".parse().unwrap()], // the email of a bichon account
)
.unwrap();
let mailer = SmtpTransport::builder_dangerous("127.0.0.1")
.port(2525)
.tls(Tls::None)
.build();
let result = mailer.send_raw(&envelope, &email.formatted());
assert!(
result.is_ok(),
"SMTP delivery should succeed, got: {:?}",
result.err()
);
}
#[test]
fn test_bichon_smtp_logic() -> Result<(), Box<dyn Error>> {
let smtp_host = "127.0.0.1";
let smtp_port = 2525;
println!("--- Testing STARTTLS Upgrade ---");
let tls_parameters = TlsParameters::builder(smtp_host.to_string())
.dangerous_accept_invalid_certs(true)
.build()?;
let mailer = SmtpTransport::starttls_relay(smtp_host)?
.port(smtp_port)
.tls(Tls::Required(tls_parameters))
.authentication(vec![Mechanism::Login, Mechanism::Plain])
.credentials(Credentials::new(
"test_user".to_string(),
"hP1Z4ZBs4IjdXtjbImFoX9kM".to_string(),
))
.build();
// If RCPT TO is not explicitly specified in the envelope, the addresses in the 'To' header
// will be treated as envelope recipients. Bichon enforces a single-recipient policy per
// transaction; if multiple recipients are detected, it will reject with:
// "452 4.5.3 Too many recipients, try again in a new transaction".
let email = Message::builder()
.from("sender@bichon.com".parse()?)
.to("placeholder@example.com".parse()?)
.subject("TLS Test")
.body(String::from("Hello Bichon with TLS!"))?;
let result = mailer.send(&email);
assert!(
result.is_ok(),
"STARTTLS encryption or Auth failed: {:?}",
result.err()
);
println!("SUCCESS: TLS upgrade and mail delivery worked.");
Ok(())
}
+80
View File
@@ -0,0 +1,80 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use rcgen::generate_simple_self_signed;
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls::ServerConfig;
use std::io::{self, BufReader, Error, ErrorKind};
use std::sync::Arc;
use tokio::fs::File;
use tokio::io::AsyncReadExt;
use tokio_rustls::TlsAcceptor;
use crate::modules::settings::cli::SETTINGS;
pub async fn create_acceptor() -> io::Result<TlsAcceptor> {
let (certs, key) = if let (Some(key_path), Some(cert_path)) = (
&SETTINGS.bichon_smtp_tls_key_path,
&SETTINGS.bichon_smtp_tls_cert_path,
) {
load_certs_from_files(key_path, cert_path).await?
} else {
generate_self_signed()?
};
let config = ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)
.map_err(|e| Error::new(ErrorKind::InvalidData, e))?;
Ok(TlsAcceptor::from(Arc::new(config)))
}
async fn load_certs_from_files(
key_path: &str,
cert_path: &str,
) -> io::Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
let mut key_file = File::open(key_path).await?;
let mut key_data = Vec::new();
key_file.read_to_end(&mut key_data).await?;
let mut cert_file = File::open(cert_path).await?;
let mut cert_data = Vec::new();
cert_file.read_to_end(&mut cert_data).await?;
let certs: Vec<CertificateDer<'static>> =
rustls_pemfile::certs(&mut BufReader::new(cert_data.as_slice()))
.filter_map(Result::ok)
.collect();
let key = rustls_pemfile::private_key(&mut BufReader::new(key_data.as_slice()))?
.ok_or_else(|| Error::new(ErrorKind::InvalidData, "No private key found"))?;
Ok((certs, key))
}
fn generate_self_signed() -> io::Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
let subject_alt_names = vec!["localhost".to_string(), "127.0.0.1".to_string()];
let key = generate_simple_self_signed(subject_alt_names).map_err(Error::other)?;
let cert_der = CertificateDer::from(key.cert.der().to_vec());
let key_der = PrivateKeyDer::try_from(key.signing_key.serialize_der())
.map_err(|e| Error::new(ErrorKind::InvalidData, e))?;
Ok((vec![cert_der], key_der))
}
+7
View File
@@ -116,6 +116,9 @@ impl Permission {
/// Authorization requires checking access to the target account_id.
pub const DATA_IMPORT_BATCH: &str = "data:import:batch";
/// Allow SMTP ingestion into SPECIFIC accounts.
pub const DATA_SMTP_INGEST: &str = "data:smtp:ingest";
pub fn global_permissions() -> Vec<(&'static str, &'static str)> {
vec![
(
@@ -194,6 +197,10 @@ impl Permission {
Self::DATA_IMPORT_BATCH,
"Import external EML/PST data into authorized accounts.",
),
(
Self::DATA_SMTP_INGEST,
"Receive and archive emails via SMTP for authorized accounts.",
),
]
}
+1
View File
@@ -141,6 +141,7 @@ impl BuiltinRole {
Permission::DATA_DELETE,
Permission::DATA_EXPORT_BATCH,
Permission::DATA_IMPORT_BATCH,
Permission::DATA_SMTP_INGEST,
]
.into_iter()
.collect()