1 parent
96a32ea8fb
commit
a643e7e3ab
106 files changed
+2223
-4669
No files matched your search
+7
-151
@@ -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(),
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user