Files
RustBox/src/routes/send.rs
T
Mateusz Gruszczyński e3e844ef4f
RustBox CI / rustbox (push) Canceled after 0s
v0.1.2
2026-10-31 09:22:42 +01:00

587 lines
19 KiB
Rust

use std::path::Path;
use std::sync::Arc;
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use serde::{Deserialize, Serialize};
use crate::auth::session::SessionState;
use crate::config::AppConfig;
use crate::db;
use crate::error::AppError;
use crate::imap::client::{ImapClient, ImapCredentials};
use crate::smtp::client::{AttachmentData, SendableMessage, SmtpClient, SmtpCredentials};
use crate::smtp::types::{PgpMode, PgpSendConfig};
#[derive(Deserialize)]
pub struct SendRequest {
pub to: Vec<String>,
#[serde(default)]
pub cc: Vec<String>,
#[serde(default)]
pub bcc: Vec<String>,
#[serde(default)]
pub subject: String,
pub text_body: Option<String>,
pub html_body: Option<String>,
pub in_reply_to: Option<String>,
pub references: Option<String>,
pub draft_id: Option<String>,
pub from_identity_id: Option<i64>,
#[serde(default)]
pub request_mdn: bool,
#[serde(default = "default_priority")]
pub priority: String,
pub pgp_mode: Option<String>,
pub pgp_inline: Option<bool>,
pub pgp_passphrase: Option<String>,
#[serde(default)]
pub pgp_attach_public_key: bool,
#[serde(default)]
pub pgp_attach_recipient_keys: bool,
}
fn default_priority() -> String {
"normal".to_string()
}
fn validate_mailbox(label: &str, value: &str) -> Result<(), AppError> {
value
.parse::<lettre::message::Mailbox>()
.map(|_| ())
.map_err(|_| AppError::BadRequest(format!("Invalid {label} address: {value}")))
}
#[derive(Debug, Serialize)]
pub struct SendResponse {
pub status: String,
pub message_id: String,
}
pub async fn send_message_handler(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Extension(smtp_client): Extension<Arc<dyn SmtpClient>>,
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
Json(req): Json<SendRequest>,
) -> Result<Response, AppError> {
if let Some(ref draft_id) = req.draft_id {
db::drafts::validate_draft_id(draft_id).map_err(AppError::BadRequest)?;
}
if req.to.is_empty() && req.cc.is_empty() && req.bcc.is_empty() {
return Err(AppError::BadRequest(
"At least one recipient is required".to_string(),
));
}
let has_subject = !req.subject.trim().is_empty();
let has_text = req
.text_body
.as_deref()
.is_some_and(|t| !t.trim().is_empty());
let has_html = req
.html_body
.as_deref()
.is_some_and(|h| !h.trim().is_empty());
if !has_subject && !has_text && !has_html {
return Err(AppError::BadRequest(
"Subject or body is required".to_string(),
));
}
let priority = req.priority.trim().to_ascii_lowercase();
if !matches!(priority.as_str(), "high" | "normal" | "low") {
return Err(AppError::BadRequest(
"Priority must be high, normal, or low".to_string(),
));
}
if session.smtp_host.trim().is_empty() {
return Err(AppError::ServiceUnavailable(
"SMTP server is not configured for this account".to_string(),
));
}
let smtp_creds = SmtpCredentials {
host: session.smtp_host.clone(),
port: session.smtp_port,
tls: session.smtp_tls,
tls_verify: session.smtp_tls_verify,
email: session.email.clone(),
password: session.password.clone(),
};
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?;
let selected_identity = if let Some(identity_id) = req.from_identity_id {
Some(
db::identities::get_identity(&conn, identity_id)
.map_err(AppError::InternalError)?
.ok_or_else(|| AppError::NotFound("Identity not found".to_string()))?,
)
} else {
db::identities::get_default_identity(&conn).map_err(AppError::InternalError)?
};
let receipt_address = selected_identity
.as_ref()
.map(|identity| identity.email.clone())
.unwrap_or_else(|| session.email.clone());
let from_address = selected_identity
.as_ref()
.map(|identity| {
if identity.display_name.is_empty() {
identity.email.clone()
} else {
let display_name = identity
.display_name
.replace('\\', "\\\\")
.replace('\"', "\\\"");
format!("\"{display_name}\" <{}>", identity.email)
}
})
.unwrap_or_else(|| session.email.clone());
let reply_to = selected_identity.as_ref().and_then(|identity| {
let value = identity.reply_to.trim();
if value.is_empty() {
None
} else {
Some(value.to_string())
}
});
let organization = selected_identity.as_ref().and_then(|identity| {
let value = identity.organization.trim();
if value.is_empty() {
None
} else {
Some(value.to_string())
}
});
let mut bcc = req.bcc;
if let Some(identity) = selected_identity.as_ref() {
for address in identity
.bcc
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
{
if !bcc
.iter()
.any(|existing| existing.eq_ignore_ascii_case(address))
{
bcc.push(address.to_string());
}
}
}
let pgp_mode = resolve_pgp_mode(req.pgp_mode.as_deref(), selected_identity.as_ref())?;
if !config.pgp_enabled
&& (pgp_mode != PgpMode::None || req.pgp_attach_public_key || req.pgp_attach_recipient_keys)
{
return Err(AppError::ServiceUnavailable(
"OpenPGP is disabled by administrator policy".to_string(),
));
}
let pgp_inline = req.pgp_inline.unwrap_or_else(|| {
selected_identity
.as_ref()
.is_some_and(|identity| identity.pgp_inline_default)
});
let attached_public_key = if req.pgp_attach_public_key {
Some(resolve_sender_public_key(
&conn,
selected_identity.as_ref(),
&receipt_address,
)?)
} else {
None
};
let attached_recipient_keys = if req.pgp_attach_recipient_keys {
resolve_recipient_public_keys(&conn, &req.to, &req.cc)?
} else {
Vec::new()
};
let pgp = build_pgp_send_config(
&conn,
&config.data_dir,
&session.user_hash,
pgp_mode,
pgp_inline,
req.pgp_passphrase.as_deref().unwrap_or(""),
selected_identity.as_ref(),
&receipt_address,
&req.to,
&req.cc,
&bcc,
)?;
drop(conn);
validate_mailbox("From", &from_address)?;
for address in &req.to {
validate_mailbox("To", address)?;
}
for address in &req.cc {
validate_mailbox("Cc", address)?;
}
for address in &bcc {
validate_mailbox("Bcc", address)?;
}
if let Some(address) = reply_to.as_deref() {
validate_mailbox("Reply-To", address)?;
}
if req.request_mdn {
validate_mailbox("read receipt", &receipt_address)?;
}
let mut attachments = if let Some(ref draft_id) = req.draft_id {
load_draft_attachments(&config.data_dir, &session.user_hash, draft_id)?
} else {
vec![]
};
if let Some((key_id, armor)) = attached_public_key {
attachments.push(AttachmentData {
filename: format!("openpgp-{key_id}-public.asc"),
content_type: "application/pgp-keys".to_string(),
data: armor.into_bytes(),
content_id: None,
});
}
for (key_id, email, armor) in attached_recipient_keys {
let safe_email: String = email
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '@' | '.' | '-' | '_') {
ch
} else {
'_'
}
})
.collect();
attachments.push(AttachmentData {
filename: format!("openpgp-{key_id}-{safe_email}-public.asc"),
content_type: "application/pgp-keys".to_string(),
data: armor.into_bytes(),
content_id: None,
});
}
let message = SendableMessage {
from: from_address,
to: req.to,
cc: req.cc,
bcc,
reply_to,
organization,
subject: req.subject,
text_body: req.text_body.unwrap_or_default(),
html_body: req.html_body,
in_reply_to: req.in_reply_to,
references: req.references,
read_receipt_to: req.request_mdn.then_some(receipt_address),
priority,
extra_headers: config
.parsed_outgoing_headers()
.map_err(AppError::InternalError)?,
attachments,
pgp,
max_message_size_bytes: config.max_message_size_bytes(),
};
let sent = smtp_client
.send_message(&smtp_creds, &message)
.await
.map_err(|e| match e {
crate::smtp::client::SmtpError::MessageTooLarge { actual, limit } => {
AppError::PayloadTooLarge(format!(
"Message is {:.2} MiB after MIME encoding; configured limit is {:.2} MiB",
actual as f64 / 1024.0 / 1024.0,
limit as f64 / 1024.0 / 1024.0
))
}
other => AppError::ServiceUnavailable(format!("Failed to send email: {other}")),
})?;
let message_id = sent.message_id.clone();
let imap_creds = ImapCredentials {
host: session.imap_host.clone(),
port: session.imap_port,
tls: session.imap_tls,
tls_verify: session.imap_tls_verify,
email: session.email.clone(),
password: session.password.clone(),
};
{
let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash);
let sent_folder = database
.run(|conn| {
Ok(db::special_folders::get_role(conn, "sent")?
.or(db::folders::find_folder_by_attribute(conn, "\\Sent")?)
.unwrap_or_else(|| "Sent".to_string()))
})
.await
.unwrap_or_else(|_| "Sent".to_string());
match imap_client
.append_message(&imap_creds, &sent_folder, &sent.rfc822, &["\\Seen"])
.await
{
Ok(()) => {
let folder = sent_folder.clone();
if let Err(e) = database
.run(move |conn| db::folders::invalidate_folder_freshness(conn, &folder))
.await
{
tracing::warn!(error = %e, folder = %sent_folder, "Failed to invalidate sent folder cache after IMAP append");
}
}
Err(e) => {
tracing::warn!(error = %e, folder = %sent_folder, "Failed to append sent message to configured IMAP sent folder");
}
}
}
if let Some(ref draft_id) = req.draft_id {
if let Ok(conn) = db::pool::open_user_db(&config.data_dir, &session.user_hash)
&& let Ok(Some(draft)) = db::drafts::get_draft(&conn, draft_id)
{
crate::routes::drafts::remove_remote_draft_snapshot(
imap_client.as_ref(),
&imap_creds,
&draft,
)
.await;
}
cleanup_draft(&config.data_dir, &session.user_hash, draft_id).await;
}
Ok(Json(SendResponse {
status: "sent".to_string(),
message_id,
})
.into_response())
}
fn load_draft_attachments(
data_dir: &str,
user_hash: &str,
draft_id: &str,
) -> Result<Vec<AttachmentData>, AppError> {
let conn = db::pool::open_user_db(data_dir, user_hash)
.map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?;
let db_attachments =
db::drafts::get_draft_attachments(&conn, draft_id).map_err(AppError::InternalError)?;
let mut attachments = Vec::new();
for att in db_attachments {
let data = std::fs::read(&att.file_path).map_err(|e| {
AppError::InternalError(format!(
"Failed to read attachment file '{}': {e}",
att.filename
))
})?;
attachments.push(AttachmentData {
filename: att.filename,
content_type: att.content_type,
data,
content_id: Some(att.id),
});
}
Ok(attachments)
}
async fn cleanup_draft(data_dir: &str, user_hash: &str, draft_id: &str) {
if let Ok(conn) = db::pool::open_user_db(data_dir, user_hash)
&& let Err(e) = db::drafts::delete_draft(&conn, draft_id)
{
tracing::warn!(error = %e, "Failed to delete draft after send");
}
let att_dir = Path::new(data_dir)
.join(user_hash)
.join("attachments")
.join(draft_id);
if att_dir.exists()
&& let Err(e) = tokio::fs::remove_dir_all(&att_dir).await
{
tracing::warn!(error = %e, path = %att_dir.display(), "Failed to clean up attachment directory");
}
}
fn resolve_recipient_public_keys(
conn: &crate::db::pool::Connection,
to: &[String],
cc: &[String],
) -> Result<Vec<(String, String, String)>, AppError> {
let mut resolved = Vec::new();
let mut seen = std::collections::HashSet::new();
for value in to.iter().chain(cc) {
let email = mailbox_address(value)?;
let Some(key) =
db::pgp::find_key_internal_by_email(conn, &email).map_err(AppError::InternalError)?
else {
continue;
};
if seen.insert(key.record.fingerprint.clone()) {
resolved.push((key.record.key_id, email, key.record.public_key_armor));
}
}
if resolved.is_empty() {
return Err(AppError::BadRequest(
"No OpenPGP public key is available for any To/Cc recipient".to_string(),
));
}
Ok(resolved)
}
fn resolve_sender_public_key(
conn: &crate::db::pool::Connection,
identity: Option<&crate::db::identities::Identity>,
sender_email: &str,
) -> Result<(String, String), AppError> {
if let Some(fingerprint) = identity.and_then(|item| item.pgp_fingerprint.as_deref())
&& let Some(key) = db::pgp::get_key(conn, fingerprint).map_err(AppError::InternalError)?
{
return Ok((key.record.key_id, key.record.public_key_armor));
}
let key = db::pgp::find_key_internal_by_email(conn, sender_email)
.map_err(AppError::InternalError)?
.ok_or_else(|| {
AppError::BadRequest(
"No OpenPGP public key is available for the sending identity".to_string(),
)
})?;
Ok((key.record.key_id, key.record.public_key_armor))
}
fn resolve_pgp_mode(
requested: Option<&str>,
identity: Option<&db::identities::Identity>,
) -> Result<PgpMode, AppError> {
if let Some(value) = requested {
return match value.trim().to_ascii_lowercase().as_str() {
"" | "none" => Ok(PgpMode::None),
"sign" => Ok(PgpMode::Sign),
"encrypt" => Ok(PgpMode::Encrypt),
"sign-encrypt" | "sign_encrypt" | "sign+encrypt" => Ok(PgpMode::SignEncrypt),
_ => Err(AppError::BadRequest(
"pgpMode must be none, sign, encrypt, or sign-encrypt".to_string(),
)),
};
}
let Some(identity) = identity else {
return Ok(PgpMode::None);
};
Ok(
match (identity.pgp_sign_default, identity.pgp_encrypt_default) {
(true, true) => PgpMode::SignEncrypt,
(true, false) => PgpMode::Sign,
(false, true) => PgpMode::Encrypt,
(false, false) => PgpMode::None,
},
)
}
#[allow(clippy::too_many_arguments)]
fn build_pgp_send_config(
conn: &crate::db::pool::Connection,
data_dir: &str,
user_hash: &str,
mode: PgpMode,
inline: bool,
passphrase: &str,
identity: Option<&db::identities::Identity>,
sender_email: &str,
to: &[String],
cc: &[String],
bcc: &[String],
) -> Result<Option<PgpSendConfig>, AppError> {
if mode == PgpMode::None {
return Ok(None);
}
let needs_signing = matches!(mode, PgpMode::Sign | PgpMode::SignEncrypt);
let needs_encryption = matches!(mode, PgpMode::Encrypt | PgpMode::SignEncrypt);
let mut secret_key_armor = None;
if needs_signing {
let key = if let Some(fingerprint) =
identity.and_then(|identity| identity.pgp_fingerprint.as_deref())
{
db::pgp::get_key(conn, fingerprint).map_err(AppError::InternalError)?
} else {
db::pgp::find_key_internal_by_email(conn, sender_email)
.map_err(AppError::InternalError)?
}
.ok_or_else(|| {
AppError::BadRequest(
"No OpenPGP signing key is configured for this identity".to_string(),
)
})?;
let path = key.secret_key_path.ok_or_else(|| {
AppError::BadRequest("The selected OpenPGP key has no private key material".to_string())
})?;
secret_key_armor =
Some(crate::pgp::read_secret_key(&path).map_err(AppError::InternalError)?);
}
let mut recipient_public_keys = Vec::new();
if needs_encryption {
let prefs = db::pgp::get_preferences(conn).map_err(AppError::InternalError)?;
let mut seen = std::collections::HashSet::new();
for value in to.iter().chain(cc).chain(bcc) {
let email = mailbox_address(value)?;
let key = db::pgp::find_key_internal_by_email(conn, &email)
.map_err(AppError::InternalError)?
.ok_or_else(|| AppError::BadRequest(format!("Missing OpenPGP key for {email}")))?;
if key.record.trust == "untrusted" {
return Err(AppError::BadRequest(format!(
"OpenPGP key for {email} is explicitly untrusted"
)));
}
if prefs.require_trusted_encryption
&& !matches!(key.record.trust.as_str(), "trusted" | "verified")
{
return Err(AppError::BadRequest(format!(
"OpenPGP key for {email} is not trusted"
)));
}
if seen.insert(key.record.fingerprint.clone()) {
recipient_public_keys.push(key.record.public_key_armor);
}
}
if let Some(key) = db::pgp::find_key_internal_by_email(conn, sender_email)
.map_err(AppError::InternalError)?
&& seen.insert(key.record.fingerprint.clone())
{
recipient_public_keys.push(key.record.public_key_armor);
}
}
let _ = (data_dir, user_hash);
Ok(Some(PgpSendConfig {
mode,
inline,
secret_key_armor,
passphrase: passphrase.to_string(),
recipient_public_keys,
}))
}
fn mailbox_address(value: &str) -> Result<String, AppError> {
let mailbox: lettre::message::Mailbox = value
.parse()
.map_err(|_| AppError::BadRequest(format!("Invalid email address: {value}")))?;
Ok(mailbox.email.to_string().to_ascii_lowercase())
}