diff --git a/CHANGELOG.md b/CHANGELOG.md index b2e9ecd..b59a6d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # RustBox changelog +### 0.2.20 + +- Fixed interrupted initial-backfill recovery so legacy missing-date repair does not run against an invalidated or incomplete folder cache. +- Fixed the `Forward as attachment` route build failure by making `AttachMessageSourceRequest` visible at the same crate scope as the public route handler, so Axum can resolve the handler type from `routes::mod`. +- Fixed messages without a usable RFC 5322 `Date` header so mailbox synchronization falls back to the IMAP `INTERNALDATE`; cached messages with missing/invalid dates are repaired when the folder is opened instead of remaining at the end of the list. +- Fixed `Forward as attachment` so the server fetches the original RFC822 source from IMAP and stores it in the draft as a real `message/rfc822` `.eml` attachment. +- Bumped the application/source release to 0.2.20. + ### 0.2.19 - Improved vertical space management in the HTML message reader: the sandboxed message iframe now expands to the actual space remaining below the reader toolbar, message header and auxiliary bars instead of stopping at a fixed `68vh`. diff --git a/Cargo.lock b/Cargo.lock index 36b4baa..8fa8bbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3506,7 +3506,7 @@ dependencies = [ [[package]] name = "rustbox" -version = "0.2.19" +version = "0.2.20" dependencies = [ "aes-gcm", "async-imap", diff --git a/Cargo.toml b/Cargo.toml index 29afb43..0a4c540 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustbox" -version = "0.2.19" +version = "0.2.20" edition = "2024" rust-version = "1.99.1" default-run = "rustbox" diff --git a/NOTICE.md b/NOTICE.md index 6c67c7b..96d93e7 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1,6 +1,6 @@ # RustBox attribution / notice -RustBox 0.2.19 is derived from the Oxi webmail project: +RustBox 0.2.20 is derived from the Oxi webmail project: - Upstream: `c0h1b4/oxi` - Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d` diff --git a/README.md b/README.md index 7dcc6b7..9ecd82a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# RustBox 0.2.19 +# RustBox 0.2.20 RustBox is a webmail derived from `c0h1b4/oxi`. The consumed Oxi revision is recorded in `UPSTREAM_BASE`. diff --git a/RELEASE-MANIFEST.txt b/RELEASE-MANIFEST.txt index 2b73f59..6fa7879 100644 --- a/RELEASE-MANIFEST.txt +++ b/RELEASE-MANIFEST.txt @@ -1,6 +1,9 @@ -RustBox 0.2.19 release manifest +RustBox 0.2.20 release manifest -0.2.19 release changes and retained feature set: +0.2.20 release changes and retained feature set: +- fixes the Axum route visibility mismatch for `AttachMessageSourceRequest`, allowing the forward-as-attachment handler to compile from `routes::mod` +- falls back to IMAP `INTERNALDATE` for messages with a missing/invalid message `Date` and repairs cached missing dates when opening a folder +- forwards a message as a real server-created `message/rfc822` `.eml` draft attachment - expands sandboxed HTML message previews to the real remaining reader height and keeps that height synchronized across layout/fullscreen changes - adds equal-height text/HTML signature fields and a dedicated raw-HTML + visual signature editor with image insertion - keeps synchronization activity visible during page/progressive/all mailbox loads and individual message retrieval @@ -71,14 +74,14 @@ Docker examples: Operations/docs: - standalone `scripts/migrate_roundcube.py` imports Roundcube contacts/identities using RustBox config.toml and supports SQLite/MySQL/PostgreSQL targets -- README.md documents 0.2.19 configuration +- README.md documents 0.2.20 configuration - docker/README.md documents Compose variants - docs/STORAGE_SEARCH_OPERATIONS.md documents failure modes, shared-storage requirements and migration behavior - TODO.md leaves live SILO/MinIO/AWS/Ceph and real multi-node acceptance validation explicitly pending Build/release metadata: - `scripts/package-release.sh` runs the full Rust release gate before creating ZIP/SHA256 artifacts -- Cargo.toml version = 0.2.19 +- Cargo.toml version = 0.2.20 - rust-toolchain.toml pins Rust 1.99.1 - Cargo.lock is retained for reproducible application builds; the release gate generates it only when missing - CI and scripts/build-release.sh use the retained Cargo.lock (generating it only if missing), then run locked tests/Clippy/release build diff --git a/RELEASE-VALIDATION.txt b/RELEASE-VALIDATION.txt index 0822ae1..2421dbc 100644 --- a/RELEASE-VALIDATION.txt +++ b/RELEASE-VALIDATION.txt @@ -1,6 +1,9 @@ -RustBox 0.2.19 - final source validation +RustBox 0.2.20 - final source validation -0.2.19 scope: +0.2.20 scope: +- fixes the Axum route visibility mismatch for `AttachMessageSourceRequest`, allowing the forward-as-attachment handler to compile from `routes::mod` +- falls back to IMAP `INTERNALDATE` for messages with a missing/invalid message `Date` and repairs cached missing dates when opening a folder +- forwards a message as a real server-created `message/rfc822` `.eml` draft attachment - HTML message reader iframe height follows the actual remaining reader viewport rather than a fixed 68vh, with resize/fullscreen recalculation - multi-account sessions expose a top-bar quick switcher, per-browser combined notification selection, per-account notification preferences and the administrator `multi_account_enabled` feature gate - plain-text and HTML signature controls have aligned heights; raw HTML can be pasted into a modal, previewed/edited visually and extended with inline images @@ -33,15 +36,15 @@ RustBox 0.2.19 - final source validation - Composing shows only compact attachment/message size-limit values on one line - 0.2.9 realtime notification gating, browser shortcut preservation and 300-message defaults remain intact - V047-V050 are consolidated into compatibility baseline V050; V051 migrates existing tenants with adaptive HTML-mail colors and mailbox loading mode; V052 is next -- version metadata identifies 0.2.19 +- version metadata identifies 0.2.20 Completed in this environment: -- offline release validator: scripts/validate-release-static.py (420/420 checks passed) +- offline release validator: scripts/validate-release-static.py (421/421 checks passed) - browser JavaScript assembled in manifest order passes `node --check` - all JSON files parse successfully - Python helper scripts compile successfully - shell scripts pass `bash -n` -- release metadata consistently identifies 0.2.19 +- release metadata consistently identifies 0.2.20 - V050 baseline consolidation plus the V051 adaptive-colors/mailbox-loading migration are validated offline diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 2a16009..7242a88 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,10 +1,14 @@ -# RustBox 0.2.19 - implementation status +# RustBox 0.2.20 - implementation status - Draft saves are realtime-silent: Drafts folder/list updates continue, but saving a draft does not surface as a new-mail notification. -This file tracks the `0.2.19` source snapshot. Storage/search scope and outstanding acceptance work are tracked in `TODO.md`. A feature is only listed as implemented when working code exists in this source tree; partial items are called out explicitly. +This file tracks the `0.2.20` source snapshot. Storage/search scope and outstanding acceptance work are tracked in `TODO.md`. A feature is only listed as implemented when working code exists in this source tree; partial items are called out explicitly. -## 0.2.19 browser mail UX +## 0.2.20 browser mail UX +- The forward-as-attachment route request extractor is crate-visible, matching the handler visibility required by the parent router and avoiding Rust `private_interfaces`/private-type build errors. + +- Mailbox message dates fall back to IMAP `INTERNALDATE` when the RFC 5322 `Date` header is missing/invalid; stale cached missing dates are repaired on folder access. +- Forward-as-attachment creates a server-side `message/rfc822` `.eml` attachment from the original IMAP RFC822 source. - HTML message previews use the full remaining vertical reader area and recompute their iframe height as reader chrome/layout changes. - Signature management uses equal-height text and HTML fields and includes a raw-HTML modal with a sanitized visual editor and inline image import. - Mail loading activity is represented by the app-bar spinner for pagination, progressive/full-folder retrieval and individual message/draft loading. diff --git a/scripts/validate-release-static.py b/scripts/validate-release-static.py index 0b585c6..c07e984 100755 --- a/scripts/validate-release-static.py +++ b/scripts/validate-release-static.py @@ -882,6 +882,7 @@ require("intentionally run one RustBox application container" in read("docker/RE attachments_rs = read("src/routes/attachments.rs") require(".begin_upload(&key)" in attachments_rs and ".get(&attachment.storage_key)" in attachments_rs and "storage::process_queued_delete" in attachments_rs, "attachment HTTP routes use BlobStorage") require("tokio::fs::" not in attachments_rs, "attachment HTTP routes no longer access local filesystem directly") +require("pub(crate) struct AttachMessageSourceRequest" in attachments_rs, "forward-as-attachment request type matches route handler crate visibility") attachment_enqueue = attachments_rs.find("blob_delete_outbox::enqueue(&transaction") attachment_db_delete = attachments_rs.find("delete_draft_attachment(&transaction", attachment_enqueue) attachment_commit = attachments_rs.find(".commit()", attachment_db_delete) diff --git a/src/db/messages.rs b/src/db/messages.rs index 77268b6..c08735d 100644 --- a/src/db/messages.rs +++ b/src/db/messages.rs @@ -328,6 +328,24 @@ pub fn get_messages_sorted( .map_err(|e| format!("Failed to read sorted message row: {e}")) } +pub fn uids_with_missing_dates( + conn: &Connection, + folder: &str, + limit: u32, +) -> Result, String> { + let mut stmt = conn + .prepare(crate::dbq!(db_messages::Q_016)) + .map_err(|e| format!("Failed to prepare missing-date query: {e}"))?; + let rows = stmt + .query_map(params![folder, limit], |row| row.get(0)) + .map_err(|e| format!("Failed to query missing-date messages: {e}"))?; + let mut uids = Vec::new(); + for row in rows { + uids.push(row.map_err(|e| format!("Failed to read missing-date UID: {e}"))?); + } + Ok(uids) +} + pub fn count_messages(conn: &Connection, folder: &str) -> Result { conn.query_row(crate::dbq!(db_messages::Q_005), params![folder], |row| { row.get(0) diff --git a/src/imap/client.rs b/src/imap/client.rs index dfc01f2..5164a66 100644 --- a/src/imap/client.rs +++ b/src/imap/client.rs @@ -268,6 +268,22 @@ pub struct RealImapClient { pool: super::pool::ConnectionPool, } +fn resolve_message_date( + envelope_date: Option, + internal_date: Option, +) -> Option { + envelope_date + .and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() || mail_parser::DateTime::parse_rfc822(trimmed).is_none() { + None + } else { + Some(trimmed.to_string()) + } + }) + .or_else(|| internal_date.filter(|value| !value.trim().is_empty())) +} + #[async_trait] impl ImapClient for RealImapClient { async fn capabilities(&self, creds: &ImapCredentials) -> Result, ImapError> { @@ -380,7 +396,7 @@ impl ImapClient for RealImapClient { let mut fetch_stream = session .uid_fetch( uid_range, - "(UID ENVELOPE FLAGS BODYSTRUCTURE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (Message-ID In-Reply-To References Content-Class x-ms-exchange-generated-message-class)])", + "(UID ENVELOPE INTERNALDATE FLAGS BODYSTRUCTURE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (Message-ID In-Reply-To References Content-Class x-ms-exchange-generated-message-class)])", ) .await .map_err(map_imap_error)?; @@ -405,6 +421,7 @@ impl ImapClient for RealImapClient { let raw_header_bytes = fetch.header(); let parsed_threading = raw_header_bytes .and_then(|raw| mail_parser::MessageParser::default().parse(raw)); + let internal_date = fetch.internal_date().map(|value| value.to_rfc2822()); let (subject, from, to, cc, date) = if let Some(env) = fetch.envelope() { let subject = env.subject.as_ref().map(|b| decode_header_bytes(b)); @@ -427,11 +444,12 @@ impl ImapClient for RealImapClient { .map(|addrs| addrs.iter().map(imap_address_to_email).collect()) .unwrap_or_default(); - let date = env + let envelope_date = env .date .as_ref() .and_then(|b| std::str::from_utf8(b).ok()) - .map(|s| s.to_string()); + .map(str::to_string); + let date = resolve_message_date(envelope_date, internal_date.clone()); (subject, from, to, cc, date) } else { @@ -440,7 +458,13 @@ impl ImapClient for RealImapClient { folder = %folder, "ENVELOPE missing for message, headers will be empty until body is fetched" ); - (None, vec![], vec![], vec![], None) + ( + None, + vec![], + vec![], + vec![], + resolve_message_date(None, internal_date.clone()), + ) }; let message_id = parsed_threading @@ -1337,3 +1361,38 @@ impl ImapClient for RealImapClient { Ok(supported) } } + +#[cfg(test)] +mod date_resolution_tests { + use super::resolve_message_date; + + #[test] + fn uses_internal_date_when_message_date_is_missing() { + assert_eq!( + resolve_message_date(None, Some("Tue, 3 Nov 2026 14:38:39 +0100".to_string())), + Some("Tue, 3 Nov 2026 14:38:39 +0100".to_string()) + ); + } + + #[test] + fn uses_internal_date_when_message_date_is_invalid() { + assert_eq!( + resolve_message_date( + Some("not-a-date".to_string()), + Some("Tue, 3 Nov 2026 14:38:39 +0100".to_string()) + ), + Some("Tue, 3 Nov 2026 14:38:39 +0100".to_string()) + ); + } + + #[test] + fn keeps_valid_message_date() { + assert_eq!( + resolve_message_date( + Some("Mon, 2 Nov 2026 10:00:00 +0100".to_string()), + Some("Tue, 3 Nov 2026 14:38:39 +0100".to_string()) + ), + Some("Mon, 2 Nov 2026 10:00:00 +0100".to_string()) + ); + } +} diff --git a/src/imap/mock.rs b/src/imap/mock.rs index b6bf36a..12a8151 100644 --- a/src/imap/mock.rs +++ b/src/imap/mock.rs @@ -5,6 +5,7 @@ pub struct MockImapClient { folders: Mutex>, headers: Mutex>, bodies: Mutex>, + raw_messages: Mutex)>>, folder_status: Mutex>, folder_status_ext: Mutex>, should_fail: Mutex>, @@ -20,6 +21,7 @@ impl MockImapClient { folders: Mutex::new(Vec::new()), headers: Mutex::new(Vec::new()), bodies: Mutex::new(Vec::new()), + raw_messages: Mutex::new(Vec::new()), folder_status: Mutex::new(None), folder_status_ext: Mutex::new(None), should_fail: Mutex::new(None), @@ -56,6 +58,11 @@ impl MockImapClient { self } + pub fn with_raw_message(self, uid: u32, raw: Vec) -> Self { + self.raw_messages.lock().unwrap().push((uid, raw)); + self + } + pub fn with_error(self, error: ImapError) -> Self { *self.should_fail.lock().unwrap() = Some(error); self @@ -165,6 +172,27 @@ impl ImapClient for MockImapClient { }) } + async fn fetch_raw_message( + &self, + _creds: &ImapCredentials, + folder: &str, + uid: u32, + ) -> Result, ImapError> { + if let Some(ref err) = *self.should_fail.lock().unwrap() { + return Err(clone_error(err)); + } + self.raw_messages + .lock() + .unwrap() + .iter() + .find(|(message_uid, _)| *message_uid == uid) + .map(|(_, raw)| raw.clone()) + .ok_or_else(|| ImapError::MessageNotFound { + uid, + folder: folder.to_string(), + }) + } + async fn add_flags( &self, _creds: &ImapCredentials, diff --git a/src/queries/mysql/queries.rs b/src/queries/mysql/queries.rs index 1a707d9..5beeafe 100644 --- a/src/queries/mysql/queries.rs +++ b/src/queries/mysql/queries.rs @@ -325,6 +325,7 @@ pub mod db_messages { AND lower(folder) NOT LIKE '%/trash' AND lower(folder) NOT LIKE '%/junk' AND lower(folder) NOT LIKE '%/spam'"; + pub const Q_016: &str = "SELECT uid FROM messages WHERE folder = ? AND (date = '' OR date_epoch <= 0) ORDER BY uid DESC LIMIT ?"; pub const FORMAT_001: &str = "SELECT {MSG_SELECT_COLS} FROM messages WHERE folder = ? diff --git a/src/queries/pgsql/queries.rs b/src/queries/pgsql/queries.rs index 21e0c81..261d031 100644 --- a/src/queries/pgsql/queries.rs +++ b/src/queries/pgsql/queries.rs @@ -323,6 +323,7 @@ pub mod db_messages { AND lower(folder) NOT LIKE '%/trash' AND lower(folder) NOT LIKE '%/junk' AND lower(folder) NOT LIKE '%/spam'"; + pub const Q_016: &str = "SELECT uid FROM messages WHERE folder = $1 AND (date = '' OR date_epoch <= 0) ORDER BY uid DESC LIMIT $2"; pub const FORMAT_001: &str = "SELECT {MSG_SELECT_COLS} FROM messages WHERE folder = $1 diff --git a/src/queries/sqlite/queries.rs b/src/queries/sqlite/queries.rs index fffabb3..2735ea3 100644 --- a/src/queries/sqlite/queries.rs +++ b/src/queries/sqlite/queries.rs @@ -318,6 +318,7 @@ pub mod db_messages { AND lower(folder) NOT LIKE '%/trash' AND lower(folder) NOT LIKE '%/junk' AND lower(folder) NOT LIKE '%/spam'"; + pub const Q_016: &str = "SELECT uid FROM messages WHERE folder = ?1 AND (date = '' OR date_epoch <= 0) ORDER BY uid DESC LIMIT ?2"; pub const FORMAT_001: &str = "SELECT {MSG_SELECT_COLS} FROM messages WHERE folder = ?1 diff --git a/src/routes/attachments.rs b/src/routes/attachments.rs index 4f14515..1d4a2da 100644 --- a/src/routes/attachments.rs +++ b/src/routes/attachments.rs @@ -4,13 +4,15 @@ use axum::extract::Multipart; use axum::extract::Path as AxumPath; use axum::response::{IntoResponse, Response}; use axum::{Extension, Json}; -use serde::Serialize; +use bytes::Bytes; +use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::auth::session::SessionState; use crate::config::AppConfig; use crate::db; use crate::error::AppError; +use crate::imap::client::{ImapClient, ImapCredentials}; use crate::storage::{self, BlobStorage, attachment_key}; #[derive(Debug, Serialize)] @@ -26,6 +28,12 @@ struct DeleteResponse { status: String, } +#[derive(Debug, Deserialize)] +pub(crate) struct AttachMessageSourceRequest { + folder: String, + uid: u32, +} + pub async fn upload_attachment( Extension(session): Extension, Extension(config): Extension>, @@ -174,6 +182,133 @@ pub async fn upload_attachment( .into_response()) } +pub async fn attach_message_source( + Extension(session): Extension, + Extension(config): Extension>, + Extension(storage): Extension>, + Extension(imap_client): Extension>, + AxumPath(draft_id): AxumPath, + Json(req): Json, +) -> Result { + db::drafts::validate_draft_id(&draft_id).map_err(AppError::BadRequest)?; + if req.folder.trim().is_empty() || req.uid == 0 { + return Err(AppError::BadRequest( + "Message folder and UID are required".to_string(), + )); + } + + let 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 raw = imap_client + .fetch_raw_message(&creds, &req.folder, req.uid) + .await + .map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?; + if raw.is_empty() { + return Err(AppError::BadRequest("Message source is empty".to_string())); + } + if raw.len() > config.max_attachment_size_bytes() { + return Err(AppError::BadRequest(format!( + "Message source exceeds maximum attachment size of {} MiB", + config.max_attachment_size_mb + ))); + } + + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; + if db::drafts::get_draft(&conn, &draft_id) + .map_err(AppError::InternalError)? + .is_none() + { + db::drafts::upsert_draft( + &conn, &draft_id, "", "", "", "", "", None, None, None, false, "normal", "html", + "none", false, false, false, + ) + .map_err(AppError::InternalError)?; + } + + let filename = format!("message-{}.eml", req.uid); + let content_type = "message/rfc822".to_string(); + let att_id = uuid::Uuid::new_v4().to_string(); + let key = + attachment_key(&session.user_hash, &draft_id, &att_id).map_err(AppError::InternalError)?; + let size = raw.len() as i64; + let checksum = format!("{:x}", Sha256::digest(&raw)); + let mut writer = storage + .begin_upload(&key) + .await + .map_err(|e| AppError::ServiceUnavailable(format!("Storage unavailable: {e}")))?; + + if let Err(error) = writer.write(Bytes::from(raw)).await { + if writer.abort().await.is_err() + && let Err(cleanup_error) = + storage::enqueue_unreferenced_delete(&conn, storage.as_ref(), &key).await + { + tracing::error!(error = %cleanup_error, storage_key = %key, "Failed to queue generated attachment cleanup"); + } + return Err(AppError::ServiceUnavailable(format!( + "Failed to store message attachment: {error}" + ))); + } + + let stat = match writer.finish().await { + Ok(stat) => stat, + Err(error) => { + if let Err(cleanup_error) = + storage::enqueue_unreferenced_delete(&conn, storage.as_ref(), &key).await + { + tracing::error!(error = %cleanup_error, storage_key = %key, "Failed to queue failed generated attachment cleanup"); + } + return Err(AppError::ServiceUnavailable(format!( + "Failed to finalize message attachment: {error}" + ))); + } + }; + if stat.size != size as u64 { + if let Err(cleanup_error) = + storage::enqueue_unreferenced_delete(&conn, storage.as_ref(), &key).await + { + tracing::error!(error = %cleanup_error, storage_key = %key, "Failed to queue invalid generated attachment cleanup"); + } + return Err(AppError::InternalError( + "Message attachment size verification failed".to_string(), + )); + } + + let attachment = db::drafts::NewDraftAttachment { + id: &att_id, + draft_id: &draft_id, + filename: &filename, + content_type: &content_type, + size, + storage_key: &key, + checksum_sha256: Some(&checksum), + }; + if let Err(error) = db::drafts::add_draft_attachment(&conn, &attachment) { + if let Err(cleanup_error) = + storage::enqueue_unreferenced_delete(&conn, storage.as_ref(), &key).await + { + tracing::error!(error = %cleanup_error, storage_key = %key, "Failed to queue unattached generated blob cleanup"); + } + return Err(AppError::InternalError(error)); + } + + Ok(Json(serde_json::json!({ + "attachments": [UploadResponse { + id: att_id, + filename, + content_type, + size, + }], + })) + .into_response()) +} + pub async fn get_attachment_content( Extension(session): Extension, Extension(config): Extension>, diff --git a/src/routes/messages/mod.rs b/src/routes/messages/mod.rs index 87723e3..1674de8 100644 --- a/src/routes/messages/mod.rs +++ b/src/routes/messages/mod.rs @@ -95,6 +95,7 @@ pub async fn list_messages( cached_highest_modseq, folder_invalidated, mut header_sync, + missing_date_uids, ) = database .run(move |conn| { let header_sync = HeaderSync::capture(conn)?; @@ -128,11 +129,50 @@ pub async fn list_messages( highest_modseq, db::folders::is_folder_messages_invalidated(conn, &cached_folder_name)?, header_sync, + db::messages::uids_with_missing_dates(conn, &cached_folder_name, 100)?, )) }) .await .map_err(AppError::InternalError)?; + // Repair legacy missing dates only after the folder cache is structurally complete. + // Running this before an invalidated/incomplete backfill can interfere with recovery. + if !folder_invalidated && cached_count == cached_server_total && !missing_date_uids.is_empty() { + let creds = build_creds(&session, &config)?; + let uid_set = missing_date_uids + .iter() + .map(u32::to_string) + .collect::>() + .join(","); + match imap_client.fetch_headers(&creds, &folder, &uid_set).await { + Ok(headers) if !headers.is_empty() => { + let name = folder.clone(); + let engine = search_engine.clone(); + let hash = session.user_hash.clone(); + header_sync = database + .run(move |conn| { + let transaction = header_sync.begin_write(conn)?; + upsert_and_index_headers(&transaction, &name, &headers, &engine, &hash) + .map_err(|e| e.to_string())?; + let next = HeaderSync::capture(&transaction)?; + transaction.commit().map_err(|e| e.to_string())?; + Ok(next) + }) + .await + .map_err(AppError::InternalError)?; + } + Ok(_) => {} + Err(error) => { + tracing::warn!( + folder = %folder, + uids = %uid_set, + error = %error, + "Could not repair cached messages with missing dates" + ); + } + } + } + if query.refresh || !folder_fresh || folder_invalidated || cached_count < cached_server_total { let creds = build_creds(&session, &config)?; diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 85c3ab7..2255238 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -333,6 +333,10 @@ pub fn create_router_with_storage(config: Arc, services: RouterServic post(attachments::upload_attachment) .layer(DefaultBodyLimit::max(attachment_body_limit)), ) + .route( + "/drafts/{draft_id}/attachments/from-message", + post(attachments::attach_message_source), + ) .route( "/drafts/{draft_id}/attachments/{attachment_id}", delete(attachments::delete_attachment), diff --git a/src/routes/tests.rs b/src/routes/tests.rs index a232562..4d7f908 100644 --- a/src/routes/tests.rs +++ b/src/routes/tests.rs @@ -1347,6 +1347,97 @@ async fn get_messages_returns_200_with_paginated_list() { assert_eq!(messages.len(), 2); } +#[tokio::test] +async fn get_messages_repairs_cached_missing_date_from_imap_header() { + let static_dir = setup_static_dir(); + let data_dir = TempDir::new().unwrap(); + let config = test_config_with_imap( + static_dir.path().to_str().unwrap(), + data_dir.path().to_str().unwrap(), + ); + let store = test_store(); + let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); + let user_hash = crate::auth::user_data::hash_email("alice@example.com"); + let conn = + crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap(); + crate::db::folders::upsert_folder(&conn, "INBOX", None, None, "", true, 1, 1, 1, 0).unwrap(); + crate::db::messages::upsert_message( + &conn, + "INBOX", + 77, + None, + None, + None, + "No Date header", + "sender@example.com", + "Sender", + "[]", + "[]", + "", + "", + 123, + false, + "", + None, + ) + .unwrap(); + crate::db::folders::mark_folder_messages_fresh(&conn, "INBOX").unwrap(); + drop(conn); + + let repaired_date = "Tue, 3 Nov 2026 14:38:39 +0100"; + let mock = Arc::new(MockImapClient::new().with_headers(vec![ImapMessageHeader { + uid: 77, + subject: Some("No Date header".to_string()), + from: vec![EmailAddress { + name: Some("Sender".to_string()), + address: "sender@example.com".to_string(), + }], + to: vec![EmailAddress { + name: None, + address: "alice@example.com".to_string(), + }], + date: Some(repaired_date.to_string()), + flags: vec![], + has_attachments: false, + size: 123, + message_id: None, + in_reply_to: None, + references: None, + cc: vec![], + reaction: None, + }])); + let imap_client: Arc = mock.clone(); + let app = create_router( + config, + store, + imap_client, + test_smtp_client(), + test_search_engine(data_dir.path().to_str().unwrap()), + test_event_bus(), + test_idle_manager(), + ); + + let mut req = Request::builder() + .uri("/api/folders/INBOX/messages?page=0&per_page=50") + .header("x-requested-with", "XMLHttpRequest"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + req = req.header(name, value); + } + let response = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["messages"][0]["uid"], 77); + assert_eq!(json["messages"][0]["date"], repaired_date); + assert!( + mock.header_requests + .lock() + .unwrap() + .iter() + .any(|(folder, range)| folder == "INBOX" && range == "77") + ); +} + #[tokio::test] async fn get_message_returns_sanitized_html() { let static_dir = setup_static_dir(); @@ -2078,6 +2169,90 @@ async fn download_attachment_returns_400_for_non_numeric_id() { assert_eq!(response.status(), StatusCode::BAD_REQUEST); } +#[tokio::test] +async fn attach_message_source_creates_eml_draft_attachment() { + let static_dir = setup_static_dir(); + let data_dir = TempDir::new().unwrap(); + let config = test_config_with_imap( + static_dir.path().to_str().unwrap(), + data_dir.path().to_str().unwrap(), + ); + let store = test_store(); + let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); + let user_hash = crate::auth::user_data::hash_email("alice@example.com"); + provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); + + let raw = + b"From: sender@example.com\r\nTo: alice@example.com\r\nSubject: forwarded\r\n\r\nhello\r\n" + .to_vec(); + let mock = MockImapClient::new().with_raw_message(42, raw.clone()); + let imap_client: Arc = Arc::new(mock); + let app = create_router( + config, + store, + imap_client, + test_smtp_client(), + test_search_engine(data_dir.path().to_str().unwrap()), + test_event_bus(), + test_idle_manager(), + ); + + let mut req = Request::builder() + .method("POST") + .uri("/api/drafts/forward-test/attachments/from-message") + .header("content-type", "application/json") + .header("x-requested-with", "XMLHttpRequest"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + req = req.header(name, value); + } + let response = app + .clone() + .oneshot( + req.body(Body::from(r#"{"folder":"INBOX","uid":42}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let attachment = &json["attachments"][0]; + assert_eq!(attachment["filename"], "message-42.eml"); + assert_eq!(attachment["content_type"], "message/rfc822"); + assert_eq!(attachment["size"], raw.len() as i64); + let attachment_id = attachment["id"].as_str().unwrap(); + + let mut content_req = Request::builder() + .uri(format!( + "/api/drafts/forward-test/attachments/{attachment_id}/content" + )) + .header("x-requested-with", "XMLHttpRequest"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + content_req = content_req.header(name, value); + } + let content_response = app + .oneshot(content_req.body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(content_response.status(), StatusCode::OK); + assert_eq!( + content_response + .headers() + .get("content-type") + .unwrap() + .to_str() + .unwrap(), + "message/rfc822" + ); + let content = content_response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); + assert_eq!(content.as_ref(), raw.as_slice()); +} + #[tokio::test] async fn send_returns_200_on_success() { let static_dir = setup_static_dir(); diff --git a/src/web/i18n/pl.json b/src/web/i18n/pl.json index c425a2e..3a5ad60 100644 --- a/src/web/i18n/pl.json +++ b/src/web/i18n/pl.json @@ -577,6 +577,11 @@ "Markdown preview": "Podgląd Markdown", "Recovered draft": "Odzyskany szkic", "Forward as attachment": "Przekaż jako załącznik", + "Attaching message…": "Dołączanie wiadomości…", + "Message attached": "Wiadomość dołączona", + "Could not prepare draft for attachment": "Nie udało się przygotować szkicu do załączenia wiadomości", + "Message attachment was not created": "Nie utworzono załącznika z wiadomością", + "Could not attach message": "Nie udało się dołączyć wiadomości", "Open": "Otwórz", "Preview": "Podgląd", "Download": "Pobierz", diff --git a/src/web/js/60-mail-actions-search.js b/src/web/js/60-mail-actions-search.js index 07d0efa..4987db6 100644 --- a/src/web/js/60-mail-actions-search.js +++ b/src/web/js/60-mail-actions-search.js @@ -935,16 +935,38 @@ async function downloadEml() { async function forwardAsAttachment() { const detail = state.currentDetail; if (!detail) return; + openCompose("new"); + state.composeAction = "forward"; + $("#compose-title").textContent = trText("Forward as attachment"); + $("#compose-subject").value = /^fwd:/i.test(detail.subject || "") ? detail.subject : `Fwd: ${detail.subject || ""}`; + state.composeUploading = true; + setComposeUploadProgress(true, 20, trText("Attaching message…")); + updateComposeDraftDeleteAction(); try { - const blob = await fetchEmlBlob(detail); - openCompose("new"); - state.composeAction = "forward"; - $("#compose-title").textContent = trText("Forward as attachment"); - $("#compose-subject").value = /^fwd:/i.test(detail.subject || "") ? detail.subject : `Fwd: ${detail.subject || ""}`; - const safe = String(detail.subject || `message-${detail.uid}`).replace(/[\\/:*?"<>|]+/g, "_").slice(0, 120) || `message-${detail.uid}`; - const file = new File([blob], `${safe}.eml`, { type: "message/rfc822" }); - await uploadComposeFiles([file]); - } catch (error) { toast(error.message, "error"); } + const saved = await saveCurrentDraft(true, true); + if (!saved) throw new Error(trText("Could not prepare draft for attachment")); + const draftId = ensureDraftId(); + const data = await request(`/drafts/${encodeURIComponent(draftId)}/attachments/from-message`, { + method: "POST", + body: JSON.stringify({ folder: detail.folder, uid: Number(detail.uid) }), + }); + const attached = Array.isArray(data.attachments) ? data.attachments : []; + if (!attached.length) throw new Error(trText("Message attachment was not created")); + state.composeAttachments = mergeComposeAttachments(state.composeAttachments, attached); + state.composeDirty = true; + state.composeNeedsRemoteSync = true; + renderComposeAttachments(); + storeLocalDraftSnapshot(); + setComposeUploadProgress(true, 100, trText("Message attached")); + setTimeout(() => { if (!state.composeUploading) setComposeUploadProgress(false); }, 450); + } catch (error) { + setComposeUploadProgress(true, 0, error?.message || trText("Could not attach message")); + toast(error?.message || trText("Could not attach message"), "error"); + setTimeout(() => { if (!state.composeUploading) setComposeUploadProgress(false); }, 4800); + } finally { + state.composeUploading = false; + updateComposeDraftDeleteAction(); + } }