587 lines
19 KiB
Rust
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())
|
|
}
|