1 parent
9003a50eb4
commit
2486856799
21 files changed
+538
-30
No files matched your search
@@ -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
@@ -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
@@ -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,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,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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 = ?
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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>>,
|
||||||
|
|||||||
@@ -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)?;
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user