1 parent
9003a50eb4
commit
2486856799
21 files changed
+538
-30
No files matched your search
@@ -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;
|
||||
openCompose("new");
|
||||
state.composeAction = "forward";
|
||||
$("#compose-title").textContent = trText("Forward as attachment");
|
||||
$("#compose-subject").value = /^fwd:/i.test(detail.subject || "") ? detail.subject : `Fwd: ${detail.subject || ""}`;
|
||||
state.composeUploading = true;
|
||||
setComposeUploadProgress(true, 20, trText("Attaching message…"));
|
||||
updateComposeDraftDeleteAction();
|
||||
try {
|
||||
const blob = await fetchEmlBlob(detail);
|
||||
openCompose("new");
|
||||
state.composeAction = "forward";
|
||||
$("#compose-title").textContent = trText("Forward as attachment");
|
||||
$("#compose-subject").value = /^fwd:/i.test(detail.subject || "") ? detail.subject : `Fwd: ${detail.subject || ""}`;
|
||||
const safe = String(detail.subject || `message-${detail.uid}`).replace(/[\\/:*?"<>|]+/g, "_").slice(0, 120) || `message-${detail.uid}`;
|
||||
const file = new File([blob], `${safe}.eml`, { type: "message/rfc822" });
|
||||
await uploadComposeFiles([file]);
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
const saved = await saveCurrentDraft(true, true);
|
||||
if (!saved) throw new Error(trText("Could not prepare draft for attachment"));
|
||||
const draftId = ensureDraftId();
|
||||
const data = await request(`/drafts/${encodeURIComponent(draftId)}/attachments/from-message`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ folder: detail.folder, uid: Number(detail.uid) }),
|
||||
});
|
||||
const attached = Array.isArray(data.attachments) ? data.attachments : [];
|
||||
if (!attached.length) throw new Error(trText("Message attachment was not created"));
|
||||
state.composeAttachments = mergeComposeAttachments(state.composeAttachments, attached);
|
||||
state.composeDirty = true;
|
||||
state.composeNeedsRemoteSync = true;
|
||||
renderComposeAttachments();
|
||||
storeLocalDraftSnapshot();
|
||||
setComposeUploadProgress(true, 100, trText("Message attached"));
|
||||
setTimeout(() => { if (!state.composeUploading) setComposeUploadProgress(false); }, 450);
|
||||
} catch (error) {
|
||||
setComposeUploadProgress(true, 0, error?.message || trText("Could not attach message"));
|
||||
toast(error?.message || trText("Could not attach message"), "error");
|
||||
setTimeout(() => { if (!state.composeUploading) setComposeUploadProgress(false); }, 4800);
|
||||
} finally {
|
||||
state.composeUploading = false;
|
||||
updateComposeDraftDeleteAction();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user