diff --git a/backend/src/tests.rs b/backend/src/tests.rs index d1a1602..670962f 100644 --- a/backend/src/tests.rs +++ b/backend/src/tests.rs @@ -207,3 +207,78 @@ async fn send_sample_messages() { mailer.send_raw(&envelope, email.as_bytes()).unwrap(); } } + +async fn send_large_file(size_bytes: usize) -> Result> { + let smtp_port: u16 = parse_env_var("SMTP_PORT", 1025); + let mailer = AsyncSmtpTransport::::builder_dangerous("127.0.0.1".to_string()) + .port(smtp_port) + .build(); + + // generates pseudo-random bytes without any added dependencies + let body: Vec = (0..size_bytes).map(|i| (i % 251) as u8).collect(); + + let email = Message::builder() + .from("sender@example.com".parse()?) + .to("recipient@example.com".parse()?) + .subject(format!("Large attachment test ({size_bytes} bytes)")) + .multipart( + MultiPart::mixed() + .singlepart(SinglePart::plain("See attached.".to_owned())) + .singlepart( + Attachment::new("large.bin".to_owned()) + .body(body, ContentType::parse("application/octet-stream")?), + ), + )?; + + Ok(mailer.send(email).await?) +} + +#[tokio::test] +async fn receive_large_attachment() { + const SIZE: usize = 75 * 1024 * 1024; // 75 MiB + + let join = tokio::task::spawn(run()); + + for _ in 0..60 { + if get_messages_metadata().await.is_ok() { break; } + sleep(Duration::from_millis(100)).await; + } + + send_large_file(SIZE).await.expect("send failed"); + + // give storage a moment to process it, magic number + sleep(Duration::from_millis(220)).await; + + let messages = get_messages_metadata().await.unwrap(); + let meta = messages.last().expect("no message received"); + + assert_eq!(meta.attachments.len(), 1); + assert_eq!(meta.attachments[0].filename, "large.bin"); + + // fetch the full message and hit the new attachment URL + let http_port: u16 = parse_env_var("HTTP_PORT", 1080); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .unwrap(); + + let attachment_bytes = client + .get(format!( + "http://127.0.0.1:{http_port}/api/message/{}/attachment/0", + meta.id + )) + .send() + .await + .expect("attachment request failed") + .bytes() + .await + .expect("reading body failed"); + + assert_eq!(attachment_bytes.len(), SIZE); + + // verify content matches + let expected: Vec = (0..SIZE).map(|i| (i % 251) as u8).collect(); + assert_eq!(attachment_bytes.as_ref(), expected.as_slice()); + + join.abort(); +} diff --git a/backend/src/web_server.rs b/backend/src/web_server.rs index 1f60846..5df4253 100644 --- a/backend/src/web_server.rs +++ b/backend/src/web_server.rs @@ -150,7 +150,7 @@ async fn message_body_handler( ) -> Result, StatusCode> { if let Ok(storage) = state.storage.read() { match storage.get(&id) { - Some(message) => Ok(Html(message.render())), + Some(message) => Ok(Html(message.render(&state.prefix))), _ => Err(StatusCode::NOT_FOUND), } } else { @@ -199,6 +199,40 @@ async fn version_handler() -> Result, StatusCode> { Ok(Json(vi)) } +/// return raw attachment by index +async fn attachment_handler( + Path((id, index)): Path<(Uuid, usize)>, + Extension(state): Extension>, +) -> Result { + let storage = state.storage.read() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let message = storage.get(&id) + .ok_or(StatusCode::NOT_FOUND)?; + let (mime, bytes) = message.attachment_content(index).ok_or(StatusCode::NOT_FOUND)?; + Ok(Response::builder() + .header(header::CONTENT_TYPE, mime) + .body(Body::from(bytes)) + .unwrap()) +} + +/// return the raw message (plain/text) +async fn message_raw_handler( + Path(id): Path, + Extension(state): Extension>, +) -> Result { + let storage = state.storage.read() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let message = storage.get(&id) + .ok_or(StatusCode::NOT_FOUND)?; + let bytes = message.raw_bytes() + .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Response::builder() + .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") + .body(Body::from(bytes)) + .unwrap()) +} + async fn not_found() -> Response { Response::builder() .status(StatusCode::NOT_FOUND) @@ -248,6 +282,8 @@ pub async fn web_server( .route("/api/delete/{id}", post(message_delete_handler)) .route("/api/delete-all", post(message_delete_all_handler)) .route("/api/version", get(version_handler)) + .route("/api/message/{id}/attachment/{index}", get(attachment_handler)) + .route("/api/message/{id}/raw", get(message_raw_handler)) .nest_service("/static", get(static_handler)) .layer( TraceLayer::new_for_http() diff --git a/frontend/src/api.rs b/frontend/src/api.rs index d679661..a4efa60 100644 --- a/frontend/src/api.rs +++ b/frontend/src/api.rs @@ -41,3 +41,15 @@ pub async fn fetch_message(id: &str) -> MailMessage { .await .unwrap() } + +pub async fn fetch_raw(id: &str) -> String { + let url = get_api_path(&format!("message/{}/raw", id)); + + Request::get(&url) + .send() + .await + .unwrap() + .text() + .await + .unwrap() +} diff --git a/frontend/src/message_header.rs b/frontend/src/message_header.rs index 4acc759..5eb0701 100644 --- a/frontend/src/message_header.rs +++ b/frontend/src/message_header.rs @@ -1,4 +1,4 @@ -use crate::{dark_mode::toggle_body_invert, types::MailMessage}; +use crate::{api::get_api_path, dark_mode::toggle_body_invert, types::MailMessage}; use yew::{Callback, Html, Properties, function_component, html, html_nested}; #[derive(Properties, Eq, PartialEq)] @@ -51,30 +51,31 @@ pub fn view(props: &MessageHeaderProps) -> Html { {&message.subject} - - if message.envelope_recipients.len() > 1 { - {"Recipients: "} - } else { - {"Recipient: "} - } - - - - {for message.envelope_recipients.clone().into_iter().map(|addr| html_nested! { - - })} - - - + + if message.envelope_recipients.len() > 1 { + {"Recipients: "} + } else { + {"Recipient: "} + } + + + + {for message.envelope_recipients.clone().into_iter().map(|addr| html_nested! { + + })} + + +
- {message.attachments.iter().map(|a| { + {message.attachments.iter().enumerate().map(|(index, a)| { + let url = get_api_path(&format!("message/{}/attachment/{}", message.id, index)); html! { {&a.filename} {&a.size} @@ -84,7 +85,7 @@ pub fn view(props: &MessageHeaderProps) -> Html {
diff --git a/frontend/src/types.rs b/frontend/src/types.rs index 7ef76f9..c22d363 100644 --- a/frontend/src/types.rs +++ b/frontend/src/types.rs @@ -35,7 +35,7 @@ pub struct Attachment { pub content_id: Option, pub mime: String, pub size: String, - pub content: String, +// pub content: String, } #[derive(Clone, PartialEq, Eq, Deserialize, Default)] @@ -51,7 +51,7 @@ pub struct MailMessage { pub text: String, pub html: String, pub attachments: Vec, - pub raw: String, +// pub raw: String, pub headers: HashMap, pub envelope_from: String, pub envelope_recipients: Vec, diff --git a/frontend/src/view.rs b/frontend/src/view.rs index 501797a..768b1d3 100644 --- a/frontend/src/view.rs +++ b/frontend/src/view.rs @@ -1,12 +1,10 @@ use crate::{ - api::fetch_message, + api::{fetch_message, fetch_raw}, formatted::Formatted, overview::Tab, plaintext::Plaintext, types::{MailMessage, MailMessageMetadata}, }; -use base64::Engine; -use base64::engine::general_purpose; use wasm_bindgen_futures::spawn_local; use web_sys::MouseEvent; use yew::{ @@ -25,14 +23,18 @@ pub struct ViewMessageProps { #[function_component(ViewMessage)] pub fn view(props: &ViewMessageProps) -> Html { let message: UseStateHandle = use_state(Default::default); + let raw_content: UseStateHandle> = use_state(|| None); // fetch message details let id = props.message.id.clone(); let set_tab = props.set_tab.clone(); let inner_message = message.clone(); let current_tab = props.active_tab.clone(); - use_effect_with(id, |message_id| { + let raw_content_reset = raw_content.clone(); + + use_effect_with(id, move |message_id| { let message_id = message_id.clone(); + raw_content_reset.set(None); spawn_local(async move { let message = fetch_message(&message_id).await; if message.html.is_empty() && current_tab == Tab::Formatted { @@ -46,13 +48,27 @@ pub fn view(props: &ViewMessageProps) -> Html { || () }); + { + let id = props.message.id.clone(); + let raw_content = raw_content.clone(); + let tab = props.active_tab.clone(); + + use_effect_with((id, tab), move |(id, tab)| { + if *tab == Tab::Raw { + let id = id.clone(); + let raw_content = raw_content.clone(); + spawn_local(async move { + raw_content.set(Some(fetch_raw(&id).await)); + }); + } + || () + }); + } + if message.id.is_empty() { return html! {}; } - let raw = general_purpose::STANDARD.decode(&message.raw).unwrap(); - let raw = String::from_utf8_lossy(&raw).into_owned(); - let mut tabs = vec![("Raw", Tab::Raw), ("Headers", Tab::Headers)]; if !message.text.is_empty() && !message.html.is_empty() { @@ -121,7 +137,7 @@ pub fn view(props: &ViewMessageProps) -> Html { } else if props.active_tab == Tab::Raw { -
{raw}
+
{(*raw_content).clone().unwrap_or_default()}
} diff --git a/mailcrab/src/types.rs b/mailcrab/src/types.rs index 09cdb79..447b122 100644 --- a/mailcrab/src/types.rs +++ b/mailcrab/src/types.rs @@ -21,7 +21,7 @@ pub enum Action { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct AttachmentMetadata { - filename: String, + pub filename: String, mime: String, size: String, } @@ -92,6 +92,7 @@ pub struct Attachment { content_id: Option, mime: String, size: String, + #[serde(skip)] content: String, } @@ -144,7 +145,8 @@ pub struct MailMessage { headers: HashMap, text: String, html: String, - attachments: Vec, + pub attachments: Vec, + #[serde(skip)] raw: String, pub envelope_from: String, pub envelope_recipients: Vec, @@ -155,28 +157,33 @@ impl MailMessage { self.opened = true; } - pub fn render(&self) -> String { + pub fn raw_bytes(&self) -> Option> { + base64ct::Base64::decode_vec(&self.raw).ok() + } + + pub fn attachment_content(&self, index: usize) -> Option<(String, Vec)> { + let a = self.attachments.get(index)?; + let bytes = base64ct::Base64::decode_vec(&a.content).ok()?; + Some((a.mime.clone(), bytes)) + } + + pub fn render(&self, prefix: &str) -> String { if self.html.is_empty() { - self.text.clone() - } else { - let mut html = self.html.clone(); - - for attachement in &self.attachments { - if let Some(content_id) = &attachement.content_id { - let from = format!("cid:{}", content_id.trim_start_matches("cid:")); - let encoded: String = attachement - .content - .chars() - .filter(|c| !c.is_whitespace()) - .collect(); - let to = format!("data:{};base64,{}", attachement.mime, encoded); - - html = html.replace(&from, &to); - } - } + return self.text.clone(); + } + + let prefix = if prefix == "/" { "" } else { prefix }; + let mut html = self.html.clone(); - html + for (index, attachment) in self.attachments.iter().enumerate() { + if let Some(content_id) = &attachment.content_id { + let cid = format!("cid:{}", content_id.trim_start_matches("cid:")); + let url = format!("{}/api/message/{}/attachment/{}", prefix, self.id, index); + html = html.replace(&cid, &url); + } } + + html } }