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

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-03 16:07:58 +01:00
1 parent 9003a50eb4
commit 2486856799
21 files changed
+538 -30

No files matched your search

+8
View File
@@ -1,5 +1,13 @@
# RustBox changelog # 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 ### 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`. - 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`.
Generated
+1 -1
View File
@@ -3506,7 +3506,7 @@ dependencies = [
[[package]] [[package]]
name = "rustbox" name = "rustbox"
version = "0.2.19" version = "0.2.20"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"async-imap", "async-imap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustbox" name = "rustbox"
version = "0.2.19" version = "0.2.20"
edition = "2024" edition = "2024"
rust-version = "1.99.1" rust-version = "1.99.1"
default-run = "rustbox" default-run = "rustbox"
+1 -1
View File
@@ -1,6 +1,6 @@
# RustBox attribution / notice # 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` - Upstream: `c0h1b4/oxi`
- Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d` - Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d`
+1 -1
View File
@@ -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`. RustBox is a webmail derived from `c0h1b4/oxi`. The consumed Oxi revision is recorded in `UPSTREAM_BASE`.
+7 -4
View File
@@ -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 - 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 - 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 - keeps synchronization activity visible during page/progressive/all mailbox loads and individual message retrieval
@@ -71,14 +74,14 @@ Docker examples:
Operations/docs: Operations/docs:
- standalone `scripts/migrate_roundcube.py` imports Roundcube contacts/identities using RustBox config.toml and supports SQLite/MySQL/PostgreSQL targets - 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 - docker/README.md documents Compose variants
- docs/STORAGE_SEARCH_OPERATIONS.md documents failure modes, shared-storage requirements and migration behavior - 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 - TODO.md leaves live SILO/MinIO/AWS/Ceph and real multi-node acceptance validation explicitly pending
Build/release metadata: Build/release metadata:
- `scripts/package-release.sh` runs the full Rust release gate before creating ZIP/SHA256 artifacts - `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 - rust-toolchain.toml pins Rust 1.99.1
- Cargo.lock is retained for reproducible application builds; the release gate generates it only when missing - 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 - CI and scripts/build-release.sh use the retained Cargo.lock (generating it only if missing), then run locked tests/Clippy/release build
+8 -5
View File
@@ -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 - 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 - 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 - 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 - 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 - 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 - 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: 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` - browser JavaScript assembled in manifest order passes `node --check`
- all JSON files parse successfully - all JSON files parse successfully
- Python helper scripts compile successfully - Python helper scripts compile successfully
- shell scripts pass `bash -n` - 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 - V050 baseline consolidation plus the V051 adaptive-colors/mailbox-loading migration are validated offline
+7 -3
View File
@@ -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. - 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. - 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. - 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. - Mail loading activity is represented by the app-bar spinner for pagination, progressive/full-folder retrieval and individual message/draft loading.
+1
View File
@@ -882,6 +882,7 @@ require("intentionally run one RustBox application container" in read("docker/RE
attachments_rs = read("src/routes/attachments.rs") 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(".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("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_enqueue = attachments_rs.find("blob_delete_outbox::enqueue(&transaction")
attachment_db_delete = attachments_rs.find("delete_draft_attachment(&transaction", attachment_enqueue) attachment_db_delete = attachments_rs.find("delete_draft_attachment(&transaction", attachment_enqueue)
attachment_commit = attachments_rs.find(".commit()", attachment_db_delete) attachment_commit = attachments_rs.find(".commit()", attachment_db_delete)
+18
View File
@@ -328,6 +328,24 @@ pub fn get_messages_sorted(
.map_err(|e| format!("Failed to read sorted message row: {e}")) .map_err(|e| format!("Failed to read sorted message row: {e}"))
} }
pub fn uids_with_missing_dates(
conn: &Connection,
folder: &str,
limit: u32,
) -> Result<Vec<u32>, 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<u32, String> { pub fn count_messages(conn: &Connection, folder: &str) -> Result<u32, String> {
conn.query_row(crate::dbq!(db_messages::Q_005), params![folder], |row| { conn.query_row(crate::dbq!(db_messages::Q_005), params![folder], |row| {
row.get(0) row.get(0)
+63 -4
View File
@@ -268,6 +268,22 @@ pub struct RealImapClient {
pool: super::pool::ConnectionPool, pool: super::pool::ConnectionPool,
} }
fn resolve_message_date(
envelope_date: Option<String>,
internal_date: Option<String>,
) -> Option<String> {
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] #[async_trait]
impl ImapClient for RealImapClient { impl ImapClient for RealImapClient {
async fn capabilities(&self, creds: &ImapCredentials) -> Result<Vec<String>, ImapError> { async fn capabilities(&self, creds: &ImapCredentials) -> Result<Vec<String>, ImapError> {
@@ -380,7 +396,7 @@ impl ImapClient for RealImapClient {
let mut fetch_stream = session let mut fetch_stream = session
.uid_fetch( .uid_fetch(
uid_range, 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 .await
.map_err(map_imap_error)?; .map_err(map_imap_error)?;
@@ -405,6 +421,7 @@ impl ImapClient for RealImapClient {
let raw_header_bytes = fetch.header(); let raw_header_bytes = fetch.header();
let parsed_threading = raw_header_bytes let parsed_threading = raw_header_bytes
.and_then(|raw| mail_parser::MessageParser::default().parse(raw)); .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, from, to, cc, date) = if let Some(env) = fetch.envelope() {
let subject = env.subject.as_ref().map(|b| decode_header_bytes(b)); 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()) .map(|addrs| addrs.iter().map(imap_address_to_email).collect())
.unwrap_or_default(); .unwrap_or_default();
let date = env let envelope_date = env
.date .date
.as_ref() .as_ref()
.and_then(|b| std::str::from_utf8(b).ok()) .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) (subject, from, to, cc, date)
} else { } else {
@@ -440,7 +458,13 @@ impl ImapClient for RealImapClient {
folder = %folder, folder = %folder,
"ENVELOPE missing for message, headers will be empty until body is fetched" "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 let message_id = parsed_threading
@@ -1337,3 +1361,38 @@ impl ImapClient for RealImapClient {
Ok(supported) 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())
);
}
}
+28
View File
@@ -5,6 +5,7 @@ pub struct MockImapClient {
folders: Mutex<Vec<ImapFolder>>, folders: Mutex<Vec<ImapFolder>>,
headers: Mutex<Vec<ImapMessageHeader>>, headers: Mutex<Vec<ImapMessageHeader>>,
bodies: Mutex<Vec<ImapMessageBody>>, bodies: Mutex<Vec<ImapMessageBody>>,
raw_messages: Mutex<Vec<(u32, Vec<u8>)>>,
folder_status: Mutex<Option<FolderStatus>>, folder_status: Mutex<Option<FolderStatus>>,
folder_status_ext: Mutex<Option<FolderStatusExtended>>, folder_status_ext: Mutex<Option<FolderStatusExtended>>,
should_fail: Mutex<Option<ImapError>>, should_fail: Mutex<Option<ImapError>>,
@@ -20,6 +21,7 @@ impl MockImapClient {
folders: Mutex::new(Vec::new()), folders: Mutex::new(Vec::new()),
headers: Mutex::new(Vec::new()), headers: Mutex::new(Vec::new()),
bodies: Mutex::new(Vec::new()), bodies: Mutex::new(Vec::new()),
raw_messages: Mutex::new(Vec::new()),
folder_status: Mutex::new(None), folder_status: Mutex::new(None),
folder_status_ext: Mutex::new(None), folder_status_ext: Mutex::new(None),
should_fail: Mutex::new(None), should_fail: Mutex::new(None),
@@ -56,6 +58,11 @@ impl MockImapClient {
self self
} }
pub fn with_raw_message(self, uid: u32, raw: Vec<u8>) -> Self {
self.raw_messages.lock().unwrap().push((uid, raw));
self
}
pub fn with_error(self, error: ImapError) -> Self { pub fn with_error(self, error: ImapError) -> Self {
*self.should_fail.lock().unwrap() = Some(error); *self.should_fail.lock().unwrap() = Some(error);
self self
@@ -165,6 +172,27 @@ impl ImapClient for MockImapClient {
}) })
} }
async fn fetch_raw_message(
&self,
_creds: &ImapCredentials,
folder: &str,
uid: u32,
) -> Result<Vec<u8>, 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( async fn add_flags(
&self, &self,
_creds: &ImapCredentials, _creds: &ImapCredentials,
+1
View File
@@ -325,6 +325,7 @@ pub mod db_messages {
AND lower(folder) NOT LIKE '%/trash' AND lower(folder) NOT LIKE '%/trash'
AND lower(folder) NOT LIKE '%/junk' AND lower(folder) NOT LIKE '%/junk'
AND lower(folder) NOT LIKE '%/spam'"; 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} pub const FORMAT_001: &str = "SELECT {MSG_SELECT_COLS}
FROM messages FROM messages
WHERE folder = ? WHERE folder = ?
+1
View File
@@ -323,6 +323,7 @@ pub mod db_messages {
AND lower(folder) NOT LIKE '%/trash' AND lower(folder) NOT LIKE '%/trash'
AND lower(folder) NOT LIKE '%/junk' AND lower(folder) NOT LIKE '%/junk'
AND lower(folder) NOT LIKE '%/spam'"; 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} pub const FORMAT_001: &str = "SELECT {MSG_SELECT_COLS}
FROM messages FROM messages
WHERE folder = $1 WHERE folder = $1
+1
View File
@@ -318,6 +318,7 @@ pub mod db_messages {
AND lower(folder) NOT LIKE '%/trash' AND lower(folder) NOT LIKE '%/trash'
AND lower(folder) NOT LIKE '%/junk' AND lower(folder) NOT LIKE '%/junk'
AND lower(folder) NOT LIKE '%/spam'"; 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} pub const FORMAT_001: &str = "SELECT {MSG_SELECT_COLS}
FROM messages FROM messages
WHERE folder = ?1 WHERE folder = ?1
+136 -1
View File
@@ -4,13 +4,15 @@ use axum::extract::Multipart;
use axum::extract::Path as AxumPath; use axum::extract::Path as AxumPath;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::{Extension, Json}; use axum::{Extension, Json};
use serde::Serialize; use bytes::Bytes;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use crate::auth::session::SessionState; use crate::auth::session::SessionState;
use crate::config::AppConfig; use crate::config::AppConfig;
use crate::db; use crate::db;
use crate::error::AppError; use crate::error::AppError;
use crate::imap::client::{ImapClient, ImapCredentials};
use crate::storage::{self, BlobStorage, attachment_key}; use crate::storage::{self, BlobStorage, attachment_key};
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -26,6 +28,12 @@ struct DeleteResponse {
status: String, status: String,
} }
#[derive(Debug, Deserialize)]
pub(crate) struct AttachMessageSourceRequest {
folder: String,
uid: u32,
}
pub async fn upload_attachment( pub async fn upload_attachment(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -174,6 +182,133 @@ pub async fn upload_attachment(
.into_response()) .into_response())
} }
pub async fn attach_message_source(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Extension(storage): Extension<Arc<dyn BlobStorage>>,
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
AxumPath(draft_id): AxumPath<String>,
Json(req): Json<AttachMessageSourceRequest>,
) -> Result<Response, AppError> {
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( pub async fn get_attachment_content(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
+40
View File
@@ -95,6 +95,7 @@ pub async fn list_messages(
cached_highest_modseq, cached_highest_modseq,
folder_invalidated, folder_invalidated,
mut header_sync, mut header_sync,
missing_date_uids,
) = database ) = database
.run(move |conn| { .run(move |conn| {
let header_sync = HeaderSync::capture(conn)?; let header_sync = HeaderSync::capture(conn)?;
@@ -128,11 +129,50 @@ pub async fn list_messages(
highest_modseq, highest_modseq,
db::folders::is_folder_messages_invalidated(conn, &cached_folder_name)?, db::folders::is_folder_messages_invalidated(conn, &cached_folder_name)?,
header_sync, header_sync,
db::messages::uids_with_missing_dates(conn, &cached_folder_name, 100)?,
)) ))
}) })
.await .await
.map_err(AppError::InternalError)?; .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::<Vec<_>>()
.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 { if query.refresh || !folder_fresh || folder_invalidated || cached_count < cached_server_total {
let creds = build_creds(&session, &config)?; let creds = build_creds(&session, &config)?;
+4
View File
@@ -333,6 +333,10 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
post(attachments::upload_attachment) post(attachments::upload_attachment)
.layer(DefaultBodyLimit::max(attachment_body_limit)), .layer(DefaultBodyLimit::max(attachment_body_limit)),
) )
.route(
"/drafts/{draft_id}/attachments/from-message",
post(attachments::attach_message_source),
)
.route( .route(
"/drafts/{draft_id}/attachments/{attachment_id}", "/drafts/{draft_id}/attachments/{attachment_id}",
delete(attachments::delete_attachment), delete(attachments::delete_attachment),
+175
View File
@@ -1347,6 +1347,97 @@ async fn get_messages_returns_200_with_paginated_list() {
assert_eq!(messages.len(), 2); 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<dyn ImapClient> = 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] #[tokio::test]
async fn get_message_returns_sanitized_html() { async fn get_message_returns_sanitized_html() {
let static_dir = setup_static_dir(); 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); 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<dyn ImapClient> = 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] #[tokio::test]
async fn send_returns_200_on_success() { async fn send_returns_200_on_success() {
let static_dir = setup_static_dir(); let static_dir = setup_static_dir();
+5
View File
@@ -577,6 +577,11 @@
"Markdown preview": "Podgląd Markdown", "Markdown preview": "Podgląd Markdown",
"Recovered draft": "Odzyskany szkic", "Recovered draft": "Odzyskany szkic",
"Forward as attachment": "Przekaż jako załącznik", "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", "Open": "Otwórz",
"Preview": "Podgląd", "Preview": "Podgląd",
"Download": "Pobierz", "Download": "Pobierz",
+31 -9
View File
@@ -935,16 +935,38 @@ async function downloadEml() {
async function forwardAsAttachment() { async function forwardAsAttachment() {
const detail = state.currentDetail; const detail = state.currentDetail;
if (!detail) return; 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 { try {
const blob = await fetchEmlBlob(detail); const saved = await saveCurrentDraft(true, true);
openCompose("new"); if (!saved) throw new Error(trText("Could not prepare draft for attachment"));
state.composeAction = "forward"; const draftId = ensureDraftId();
$("#compose-title").textContent = trText("Forward as attachment"); const data = await request(`/drafts/${encodeURIComponent(draftId)}/attachments/from-message`, {
$("#compose-subject").value = /^fwd:/i.test(detail.subject || "") ? detail.subject : `Fwd: ${detail.subject || ""}`; method: "POST",
const safe = String(detail.subject || `message-${detail.uid}`).replace(/[\\/:*?"<>|]+/g, "_").slice(0, 120) || `message-${detail.uid}`; body: JSON.stringify({ folder: detail.folder, uid: Number(detail.uid) }),
const file = new File([blob], `${safe}.eml`, { type: "message/rfc822" }); });
await uploadComposeFiles([file]); const attached = Array.isArray(data.attachments) ? data.attachments : [];
} catch (error) { toast(error.message, "error"); } 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();
}
} }