commit
bc5fbd6ba9
180 files changed
+63750
No files matched your search
@@ -0,0 +1,590 @@
|
||||
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>,
|
||||
/// If sending from a draft, include the draft ID to load attachments
|
||||
/// and clean up after send.
|
||||
pub draft_id: Option<String>,
|
||||
/// Optional identity ID to use as the From address.
|
||||
/// If not provided, falls back to the session email.
|
||||
pub from_identity_id: Option<i64>,
|
||||
/// Request an MDN/read receipt at the selected identity/account address.
|
||||
#[serde(default)]
|
||||
pub request_mdn: bool,
|
||||
/// Message priority: high, normal, or low.
|
||||
#[serde(default = "default_priority")]
|
||||
pub priority: String,
|
||||
/// OpenPGP mode: none, sign, encrypt, or sign-encrypt. When omitted,
|
||||
/// identity defaults are used.
|
||||
pub pgp_mode: Option<String>,
|
||||
/// Override identity default for legacy inline OpenPGP. PGP/MIME is the default.
|
||||
pub pgp_inline: Option<bool>,
|
||||
/// Passphrase for the selected private key. Never persisted.
|
||||
pub pgp_passphrase: Option<String>,
|
||||
/// Attach the sender's public OpenPGP key as application/pgp-keys.
|
||||
#[serde(default)]
|
||||
pub pgp_attach_public_key: bool,
|
||||
/// Attach public OpenPGP keys for visible To/Cc recipients. Bcc is intentionally excluded.
|
||||
#[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,
|
||||
}
|
||||
|
||||
/// Handler for `POST /api/messages/send`.
|
||||
///
|
||||
/// Validates the request, sends the message via SMTP, and appends a copy
|
||||
/// to the IMAP "Sent" folder (best-effort).
|
||||
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)?;
|
||||
}
|
||||
|
||||
// Validate: at least one recipient.
|
||||
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(),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate: subject or body must be non-empty.
|
||||
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(),
|
||||
));
|
||||
}
|
||||
|
||||
// Build SMTP credentials from the authenticated account session.
|
||||
// Server endpoints are resolved from RustBox configuration at login and stored in the session.
|
||||
let smtp_creds = SmtpCredentials {
|
||||
host: session.smtp_host.clone(),
|
||||
port: session.smtp_port,
|
||||
tls: session.smtp_tls,
|
||||
email: session.email.clone(),
|
||||
password: session.password.clone(),
|
||||
};
|
||||
|
||||
// Resolve identity and OpenPGP options before the first network await.
|
||||
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())?;
|
||||
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 every envelope/header mailbox before touching SMTP. Lettre then
|
||||
// performs the actual SMTPUTF8 capability negotiation during delivery.
|
||||
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)?;
|
||||
}
|
||||
|
||||
// Load attachments from draft if draft_id is provided.
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// Build the sendable message.
|
||||
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,
|
||||
};
|
||||
|
||||
// Send via SMTP. The exact RFC822 bytes returned here are also appended
|
||||
// to IMAP Sent so PGP/MIME signatures and encryption remain identical.
|
||||
let sent = smtp_client
|
||||
.send_message(&smtp_creds, &message)
|
||||
.await
|
||||
.map_err(|e| AppError::ServiceUnavailable(format!("Failed to send email: {e}")))?;
|
||||
let message_id = sent.message_id.clone();
|
||||
|
||||
// Best-effort: append a copy to the "Sent" folder via the account's IMAP server.
|
||||
// Don't fail the SMTP send if this step fails.
|
||||
let imap_creds = ImapCredentials {
|
||||
host: session.imap_host.clone(),
|
||||
port: session.imap_port,
|
||||
tls: session.imap_tls,
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up the remote Drafts snapshot only after SMTP delivery succeeds.
|
||||
// The UID is verified against the stored UIDVALIDITY before deletion.
|
||||
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())
|
||||
}
|
||||
|
||||
/// Load attachment data from disk for a given draft.
|
||||
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)
|
||||
}
|
||||
|
||||
/// Clean up draft record and attachment files from disk after successful send.
|
||||
async fn cleanup_draft(data_dir: &str, user_hash: &str, draft_id: &str) {
|
||||
// Delete draft from DB (cascade deletes attachment records).
|
||||
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");
|
||||
}
|
||||
|
||||
// Remove the attachment directory from disk.
|
||||
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: &rusqlite::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: &rusqlite::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()) {
|
||||
if 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: &rusqlite::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);
|
||||
}
|
||||
}
|
||||
|
||||
// Include the sender's public key when available so the Sent copy can be decrypted later.
|
||||
if let Some(key) = db::pgp::find_key_internal_by_email(conn, sender_email)
|
||||
.map_err(AppError::InternalError)?
|
||||
{
|
||||
if seen.insert(key.record.fingerprint.clone()) {
|
||||
recipient_public_keys.push(key.record.public_key_armor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = (data_dir, user_hash); // kept in the signature for future key-agent storage without API churn
|
||||
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())
|
||||
}
|
||||
Reference in new issue
Block a user