1 parent
9003a50eb4
commit
2486856799
21 files changed
+535
-27
No files matched your search
@@ -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`.
|
||||
|
||||
Generated
+1
-1
@@ -3506,7 +3506,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustbox"
|
||||
version = "0.2.19"
|
||||
version = "0.2.20"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-imap",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<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> {
|
||||
conn.query_row(crate::dbq!(db_messages::Q_005), params![folder], |row| {
|
||||
row.get(0)
|
||||
|
||||
+63
-4
@@ -268,6 +268,22 @@ pub struct RealImapClient {
|
||||
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]
|
||||
impl ImapClient for RealImapClient {
|
||||
async fn capabilities(&self, creds: &ImapCredentials) -> Result<Vec<String>, 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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub struct MockImapClient {
|
||||
folders: Mutex<Vec<ImapFolder>>,
|
||||
headers: Mutex<Vec<ImapMessageHeader>>,
|
||||
bodies: Mutex<Vec<ImapMessageBody>>,
|
||||
raw_messages: Mutex<Vec<(u32, Vec<u8>)>>,
|
||||
folder_status: Mutex<Option<FolderStatus>>,
|
||||
folder_status_ext: Mutex<Option<FolderStatusExtended>>,
|
||||
should_fail: Mutex<Option<ImapError>>,
|
||||
@@ -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<u8>) -> 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<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(
|
||||
&self,
|
||||
_creds: &ImapCredentials,
|
||||
|
||||
@@ -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 = ?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+136
-1
@@ -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<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
@@ -174,6 +182,133 @@ pub async fn upload_attachment(
|
||||
.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(
|
||||
Extension(session): Extension<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
|
||||
@@ -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::<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 {
|
||||
let creds = build_creds(&session, &config)?;
|
||||
|
||||
|
||||
@@ -333,6 +333,10 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, 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),
|
||||
|
||||
@@ -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<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]
|
||||
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<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]
|
||||
async fn send_returns_200_on_success() {
|
||||
let static_dir = setup_static_dir();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -935,16 +935,38 @@ async function downloadEml() {
|
||||
async function forwardAsAttachment() {
|
||||
const detail = state.currentDetail;
|
||||
if (!detail) return;
|
||||
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"); }
|
||||
state.composeUploading = true;
|
||||
setComposeUploadProgress(true, 20, trText("Attaching message…"));
|
||||
updateComposeDraftDeleteAction();
|
||||
try {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user