v0.0.5
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-30 12:06:44 +01:00
1 parent 96a32ea8fb
commit a643e7e3ab
106 files changed
+2223 -4669

No files matched your search

+7 -151
View File
@@ -19,10 +19,6 @@ use crate::imap::client::{ImapClient, ImapCredentials};
use crate::realtime::events::{EventBus, MailEvent};
use crate::search::engine::{IndexableMessage, SearchEngine, UserIndex};
// ---------------------------------------------------------------------------
// Helper: build IMAP credentials from session + config
// ---------------------------------------------------------------------------
fn build_creds(session: &SessionState, _config: &AppConfig) -> Result<ImapCredentials, AppError> {
Ok(ImapCredentials {
host: session.imap_host.clone(),
@@ -34,26 +30,11 @@ fn build_creds(session: &SessionState, _config: &AppConfig) -> Result<ImapCreden
})
}
// ---------------------------------------------------------------------------
// Helper: validate IMAP flags from client input
// ---------------------------------------------------------------------------
/// Returns `true` if `flag` is a syntactically valid IMAP flag.
///
/// Accepts RFC 3501 system flags (`\Seen`, `\Answered`, `\Flagged`,
/// `\Deleted`, `\Draft`, `\Recent`) and keyword atoms (`Junk`,
/// `$Forwarded`, `$MDNSent`, etc.).
///
/// This is a restricted safe subset of the RFC 3501 §9 atom grammar:
/// an optional leading `\` followed by `[A-Za-z0-9$_\-.+]`, capped at
/// 64 characters. The cap is well above any flag seen in practice and
/// keeps the set narrow enough to exclude whitespace, parentheses, and
/// control characters without needing to enumerate every RFC exclusion.
fn is_valid_flag(flag: &str) -> bool {
if flag.is_empty() || flag.len() > 64 {
return false;
}
// Strip optional leading backslash (system flags like \Seen).
let atom = flag.strip_prefix('\\').unwrap_or(flag);
!atom.is_empty()
&& atom
@@ -70,19 +51,8 @@ fn validate_flags(flags: &[String]) -> Result<(), AppError> {
Ok(())
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
/// How many seconds a folder's message cache is considered fresh.
const FOLDER_MESSAGES_TTL_SECS: u32 = 30;
/// `GET /api/folders/:folder/messages?page=0&per_page=50`
///
/// Returns paginated messages using a cache-first strategy:
/// 1. If the folder was synced within `FOLDER_MESSAGES_TTL_SECS`, serve from cache.
/// 2. Otherwise do a lightweight IMAP SELECT to check for new messages and sync
/// only what's new.
pub async fn list_messages(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
@@ -154,7 +124,6 @@ pub async fn list_messages(
if !folder_fresh {
let creds = build_creds(&session, &config)?;
// Do a lightweight IMAP SELECT to get folder status.
let status = imap_client
.folder_status(&creds, &folder)
.await
@@ -201,8 +170,6 @@ pub async fn list_messages(
|| folder_invalidated;
if needs_sync {
// Cold start: no cached messages at all — fetch the ~100 most recent
// synchronously for a fast first paint, then background-sync the rest.
if (needs_full_sync || cached_count == 0) && status.exists > 0 {
let recent_start = status.uid_next.saturating_sub(100).max(1);
let recent_range = format!("{recent_start}:*");
@@ -233,7 +200,6 @@ pub async fn list_messages(
.await
.map_err(AppError::InternalError)?;
// Spawn background task to fetch the remaining older headers.
if recent_start > 1 {
let remaining_range = (1, recent_start - 1);
tokio::spawn(sync_remaining_headers(BackgroundSyncParams {
@@ -252,8 +218,6 @@ pub async fn list_messages(
syncing = true;
}
} else {
// An interrupted initial sync can leave gaps below the highest
// cached UID. Reconcile all server UIDs, fetching only missing ones.
let uid_range = (1, status.uid_next.saturating_sub(1));
tokio::spawn(sync_remaining_headers(BackgroundSyncParams {
@@ -357,11 +321,6 @@ pub async fn list_messages(
.into_response())
}
// ---------------------------------------------------------------------------
// Helpers for eager loading
// ---------------------------------------------------------------------------
/// Upsert fetched headers into the database and index them for search.
fn upsert_and_index_headers(
conn: &rusqlite::Transaction<'_>,
folder: &str,
@@ -369,7 +328,6 @@ fn upsert_and_index_headers(
search_engine: &Arc<SearchEngine>,
user_hash: &str,
) -> Result<(), AppError> {
// Caller holds a checked HeaderSync write transaction through indexing.
let sync_keywords = db::mail_preferences::get_preferences(conn)
.map(|prefs| prefs.sync_imap_keywords)
.unwrap_or(false);
@@ -411,7 +369,6 @@ fn upsert_and_index_headers(
)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
// Populate denormalized known_addresses table.
db::contacts::populate_known_addresses(conn, from_address, from_name, &to_json, &cc_json)
.map_err(|e| AppError::InternalError(format!("Known addresses error: {e}")))?;
if sync_keywords {
@@ -420,7 +377,6 @@ fn upsert_and_index_headers(
}
}
// Index for search (skip Spam/Junk/Trash).
if !headers.is_empty()
&& !UserIndex::is_excluded_folder(folder)
&& let Ok(user_index) = search_engine.open_user_index(user_hash)
@@ -452,7 +408,6 @@ fn upsert_and_index_headers(
Ok(())
}
/// Bound each FETCH by existing messages, not by the size of gaps between UIDs.
fn header_chunks(uids: &[u32]) -> impl Iterator<Item = String> + '_ {
uids.chunks(250).map(|chunk| {
chunk
@@ -463,7 +418,6 @@ fn header_chunks(uids: &[u32]) -> impl Iterator<Item = String> + '_ {
})
}
/// Parameters for a background sync task, bundled to avoid too-many-arguments.
struct BackgroundSyncParams {
creds: ImapCredentials,
folder: String,
@@ -478,8 +432,6 @@ struct BackgroundSyncParams {
exists: u32,
}
/// Background task: fetch remaining headers for a folder and notify the
/// frontend via EventBus when done. All errors are logged but non-fatal.
async fn sync_remaining_headers(params: BackgroundSyncParams) {
let BackgroundSyncParams {
creds,
@@ -567,9 +519,7 @@ async fn sync_remaining_headers(params: BackgroundSyncParams) {
Ok(superseded)
})
.await;
// A completed mutation may invalidate a backfill in another folder.
// Wake the client to retry missing UIDs with fresh IMAP reads, not
// the rejected headers. Network errors wait for the next request.
if matches!(superseded, Ok(true)) {
event_bus
.publish(&user_hash, MailEvent::FolderUpdated)
@@ -579,10 +529,6 @@ async fn sync_remaining_headers(params: BackgroundSyncParams) {
}
}
/// `GET /api/messages/:folder/:uid`
///
/// Returns the full message detail including body and attachment metadata.
/// Fetches from IMAP and caches if not already cached.
pub async fn get_message(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
@@ -592,16 +538,12 @@ pub async fn get_message(
) -> Result<Response, AppError> {
let creds = build_creds(&session, &config)?;
// Open the per-user database.
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
// Check SQLite cache first.
let cached_body = db::messages::get_cached_body(&conn, &folder, uid)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
// Treat a cache hit with missing attachments_json as stale (pre-V006 cache).
// Re-fetch from IMAP so attachments and inline images are properly resolved.
let usable_cache = cached_body.filter(|c| c.attachments_json.is_some());
let fetched_body = usable_cache.is_none();
@@ -638,15 +580,11 @@ pub async fn get_message(
)
}
} else {
// Fetch from IMAP.
let body = imap_client
.fetch_body(&creds, &folder, uid)
.await
.map_err(|e| match e {
crate::imap::client::ImapError::MessageNotFound { .. } => {
// Message exists in local DB but not on IMAP server —
// remove the stale cache entry, search index entry, and
// invalidate the folder so the list refreshes.
let _ = db::messages::delete_message(&conn, &folder, uid);
let _ = db::folders::invalidate_folder_freshness(&conn, &folder);
if let Ok(user_index) = search_engine.open_user_index(&session.user_hash) {
@@ -655,9 +593,6 @@ pub async fn get_message(
AppError::NotFound(format!("Message UID {uid} not found in folder {folder}"))
}
other => {
// No cached body and IMAP is unreachable — this message
// can't be served. Remove the stale DB entry so it
// disappears from the message list.
let _ = db::messages::delete_message(&conn, &folder, uid);
let _ = db::folders::invalidate_folder_freshness(&conn, &folder);
if let Ok(user_index) = search_engine.open_user_index(&session.user_hash) {
@@ -667,7 +602,6 @@ pub async fn get_message(
}
})?;
// Use HTML directly (frontend sandbox handles security).
let sanitized_html = body.text_html.clone();
let attachment_meta: Vec<AttachmentMeta> = body
@@ -683,9 +617,6 @@ pub async fn get_message(
})
.collect();
// Rewrite cid: URLs in the HTML to inline data URIs so the
// sandboxed iframe can display embedded images without needing
// network access.
let resolved_html = sanitized_html.map(|mut html| {
for att in &body.attachments {
if let Some(ref cid) = att.content_id {
@@ -701,7 +632,6 @@ pub async fn get_message(
html
});
// Serialize attachment metadata for caching.
let att_json = serde_json::to_string(&attachment_meta).ok();
let detected_theme = resolved_html
@@ -730,17 +660,12 @@ pub async fn get_message(
)
};
// Get the message header from cache (use efficient single-message lookup).
// If the header hasn't been synced yet (e.g. DB was cleared and sync is
// still running), fall back to parsing the raw headers we already fetched.
let msg = db::messages::get_single_message(&conn, &folder, uid)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let msg = match msg {
Some(m) => m,
None => {
// Parse header fields from raw_headers so we can still return a
// useful response even when the message list hasn't been synced.
let parsed = mail_parser::MessageParser::default().parse(raw_headers.as_bytes());
let subject = parsed
.as_ref()
@@ -817,8 +742,6 @@ pub async fn get_message(
}
};
// Re-index message with full body text for search.
// Skip indexing for Spam/Junk/Trash folders.
if fetched_body
&& let Some(ref text) = body_text
&& !UserIndex::is_excluded_folder(&folder)
@@ -843,7 +766,6 @@ pub async fn get_message(
});
}
// Build thread using full References chain.
let thread_messages = if let Some(ref message_id) = msg.message_id {
db::messages::get_full_thread(&conn, message_id, msg.references_header.as_deref())
.unwrap_or_default()
@@ -901,9 +823,6 @@ pub async fn get_message(
.into_response())
}
/// `PATCH /api/messages/:folder/:uid/flags`
///
/// Updates message flags on the IMAP server and in the SQLite cache.
pub async fn update_flags(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
@@ -915,7 +834,6 @@ pub async fn update_flags(
let creds = build_creds(&session, &config)?;
// Convert flags to &str slices for the IMAP client.
let flag_refs: Vec<&str> = body.flags.iter().map(|s| s.as_str()).collect();
if body.add {
@@ -930,7 +848,6 @@ pub async fn update_flags(
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?;
}
// Update SQLite cache: read current flags, add/remove, write back.
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
@@ -965,17 +882,12 @@ pub async fn update_flags(
db::messages::update_message_flags(&conn, &folder, uid, &new_flags_csv)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
// Refresh unread count after flag change.
db::folders::refresh_unread_count(&conn, &folder)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
Ok(Json(serde_json::json!({ "status": "ok" })).into_response())
}
/// `POST /api/messages/move`
///
/// Moves a message from one folder to another on the IMAP server and
/// removes it from the source folder in SQLite cache.
pub async fn move_message_handler(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
@@ -991,8 +903,6 @@ pub async fn move_message_handler(
let creds = build_creds(&session, &config)?;
// Snapshot the cached source metadata before the IMAP MOVE removes the source UID.
// Do not keep a rusqlite connection alive across an await.
let source_snapshot = {
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
@@ -1004,10 +914,6 @@ pub async fn move_message_handler(
.map(|message| !message.flags.contains("\\Seen"))
.unwrap_or(false);
// async-imap currently discards COPYUID from UID MOVE. Capture UIDNEXT before
// the move, then resolve the new UID from the moved message's exact Message-ID.
// If the server cannot provide enough information, the move still succeeds but
// the UI will intentionally not offer an unsafe Undo action.
let destination_uid_floor = if source_snapshot
.as_ref()
.and_then(|message| message.message_id.as_deref())
@@ -1022,7 +928,6 @@ pub async fn move_message_handler(
None
};
// Move on IMAP server.
imap_client
.move_message(&creds, &body.from_folder, body.uid, &body.to_folder)
.await
@@ -1068,14 +973,9 @@ pub async fn move_message_handler(
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
// Delete from source folder cache. We don't keep the row in the destination
// because the UID changes after an IMAP MOVE, and a stale UID would cause
// 404s when trying to fetch the message body.
db::messages::remove_after_mutation(&conn, &body.from_folder, body.uid)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
// Remove the old folder/UID pair from search too. Destination UIDs are
// discovered during folder sync; Spam/Junk intentionally remain unindexed.
let user_hash = session.user_hash.clone();
let from_folder = body.from_folder.clone();
let uid = body.uid;
@@ -1085,25 +985,20 @@ pub async fn move_message_handler(
})
.await;
if !matches!(search_result, Ok(Ok(()))) {
// The IMAP move already succeeded: do not trigger a false client rollback.
tracing::warn!(
?search_result,
"Failed to remove moved message from search index"
);
}
// Refresh source folder unread count (now accurate since the row is gone).
db::folders::refresh_unread_count(&conn, &body.from_folder)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
// Bump destination folder unread count if the moved message was unread.
if was_unread {
db::folders::adjust_unread_count(&conn, &body.to_folder, 1)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
}
// Invalidate destination folder cache so the next list request forces an
// IMAP resync and picks up the moved message with its new UID.
db::folders::invalidate_folder_freshness(&conn, &body.to_folder)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
@@ -1118,9 +1013,6 @@ pub async fn move_message_handler(
Ok(Json(serde_json::json!({ "status": "ok", "undo_move": undo_move })).into_response())
}
/// `POST /api/messages/copy`
///
/// Copies a message to another IMAP folder and invalidates the destination cache.
pub async fn copy_message_handler(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
@@ -1146,9 +1038,6 @@ pub async fn copy_message_handler(
Ok(Json(serde_json::json!({ "status": "ok" })).into_response())
}
/// `GET /api/messages/:folder/:uid/attachments/:attachment_id`
///
/// Downloads an attachment by its index from the message.
pub async fn download_attachment(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
@@ -1157,12 +1046,10 @@ pub async fn download_attachment(
) -> Result<Response, AppError> {
let creds = build_creds(&session, &config)?;
// Parse the attachment index.
let index: usize = attachment_id
.parse()
.map_err(|_| AppError::BadRequest(format!("Invalid attachment id: {attachment_id}")))?;
// Fetch the full message body from IMAP.
let body = imap_client
.fetch_body(&creds, &folder, uid)
.await
@@ -1173,28 +1060,21 @@ pub async fn download_attachment(
other => AppError::ServiceUnavailable(format!("IMAP error: {other}")),
})?;
// Find the attachment by index.
let attachment = body.attachments.into_iter().nth(index).ok_or_else(|| {
AppError::NotFound(format!(
"Attachment {attachment_id} not found on message UID {uid}"
))
})?;
// Build the response with appropriate headers.
let raw_filename = attachment
.filename
.unwrap_or_else(|| format!("attachment_{index}"));
// Strip ASCII control characters (U+0000-U+001F and U+007F). MIME
// decoders can surface them via quoted-pair escapes in malformed emails,
// and HeaderValue rejects those bytes. HeaderValue::from_str routes
// through is_valid (accepts 0x09, 0x20-0x7E, 0x80-0xFF; rejects
// 0x00-0x08, 0x0A-0x1F, 0x7F), so non-ASCII UTF-8 bytes are fine.
let filename: String = raw_filename
.chars()
.filter(|c| !c.is_ascii_control())
.collect();
// Fall back to a safe default if stripping left nothing (e.g. the
// original filename was composed entirely of control characters).
let filename = if filename.is_empty() {
format!("attachment_{index}")
} else {
@@ -1203,8 +1083,6 @@ pub async fn download_attachment(
let content_type =
preview_content_type(&attachment.content_type, Some(&filename), &attachment.data);
// Use inline disposition for types the browser can display natively
// (PDF, images) so the preview works; use attachment for everything else.
let is_inline = content_type == "application/pdf"
|| content_type.starts_with("image/")
|| content_type.starts_with("text/");
@@ -1234,14 +1112,8 @@ fn preview_content_type(content_type: &str, filename: Option<&str>, data: &[u8])
}
}
/// Maximum size accepted for importing a single RFC822/.eml message.
const MAX_EML_IMPORT_SIZE: usize = 50 * 1024 * 1024;
/// `POST /api/folders/:folder/import-eml`
///
/// Imports one raw RFC822 message into the selected IMAP folder using APPEND.
/// The server remains the source of truth: after APPEND the local folder cache
/// is invalidated so the next list request discovers the server-assigned UID.
pub async fn import_eml(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
@@ -1279,8 +1151,6 @@ pub async fn import_eml(
));
}
// Reject arbitrary blobs early. We do not rewrite or normalize the source: the
// exact uploaded RFC822 bytes are appended to IMAP after this structural check.
if mail_parser::MessageParser::default()
.parse(bytes.as_ref())
.is_none()
@@ -1314,7 +1184,6 @@ pub async fn import_eml(
.into_response())
}
/// Download the complete RFC822 source as an .eml file.
pub async fn download_eml_source(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
@@ -1402,9 +1271,6 @@ mod preview_tests {
}
}
/// `DELETE /api/messages/:folder/:uid`
///
/// Permanently removes a message from the IMAP server and SQLite cache.
pub async fn delete_message_handler(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
@@ -1413,19 +1279,16 @@ pub async fn delete_message_handler(
) -> Result<Response, AppError> {
let creds = build_creds(&session, &config)?;
// Expunge on IMAP server.
imap_client
.expunge_message(&creds, &folder, uid)
.await
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?;
// Delete from SQLite cache.
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
db::messages::remove_after_mutation(&conn, &folder, uid)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
// Refresh unread count for folder.
db::folders::refresh_unread_count(&conn, &folder)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
@@ -1539,8 +1402,6 @@ mod sync_tests {
exists: 1,
}));
// The IMAP batch has already been snapshotted. Complete the real handler
// before allowing the background writer to see those stale headers.
gate.0.notified().await;
let imap_client: Arc<dyn ImapClient> = client.clone();
let response = if move_message {
@@ -1583,7 +1444,7 @@ mod sync_tests {
"superseded sync must request fresh missing-UID reconciliation"
);
assert!(events.try_recv().is_err());
// A move's index deletion also must not be undone by rejected headers.
if move_message {
let index = search_engine.open_user_index(user_hash).unwrap();
assert!(
@@ -1599,8 +1460,6 @@ mod sync_tests {
);
}
// New destination UIDs (even the same numeric UID in another folder)
// remain valid, as do future arrivals in the source folder.
let next_sync = HeaderSync::capture(&conn).unwrap();
let transaction = next_sync.begin_write(&conn).unwrap();
upsert_and_index_headers(
@@ -1744,8 +1603,6 @@ mod sync_tests {
MailEvent::FolderUpdated
));
// The next actual list request must recover UIDs below max_cached_uid.
// A fresh server snapshot omits only the UID deleted in this folder.
let remaining = (1..=1000)
.filter(|uid| other_folder || *uid != 950)
.map(sync_header)
@@ -1868,7 +1725,6 @@ mod sync_tests {
#[tokio::test]
async fn empty_background_sync_records_completion() {
// Both an exhausted range and an empty server listing are successful syncs.
for uid_range in [(1, 0), (1, 100)] {
let data_dir = tempfile::tempdir().unwrap();
let path = data_dir.path().to_str().unwrap();
@@ -1893,7 +1749,7 @@ mod sync_tests {
host: "localhost".into(),
port: 143,
tls: false,
tls_verify: false,
tls_verify: false,
email: "test@example.com".into(),
password: "test".into(),
},
@@ -1963,7 +1819,7 @@ mod sync_tests {
host: "localhost".into(),
port: 143,
tls: false,
tls_verify: false,
tls_verify: false,
email: "test@example.com".into(),
password: "test".into(),
},