Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions backend/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response, Box<dyn std::error::Error>> {
let smtp_port: u16 = parse_env_var("SMTP_PORT", 1025);
let mailer = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous("127.0.0.1".to_string())
.port(smtp_port)
.build();

// generates pseudo-random bytes without any added dependencies
let body: Vec<u8> = (0..size_bytes).map(|i| (i % 251) as u8).collect();

let email = Message::builder()
.from("[email protected]".parse()?)
.to("[email protected]".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<u8> = (0..SIZE).map(|i| (i % 251) as u8).collect();
assert_eq!(attachment_bytes.as_ref(), expected.as_slice());

join.abort();
}
38 changes: 37 additions & 1 deletion backend/src/web_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ async fn message_body_handler(
) -> Result<Html<String>, 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 {
Expand Down Expand Up @@ -199,6 +199,40 @@ async fn version_handler() -> Result<Json<VersionInfo>, StatusCode> {
Ok(Json(vi))
}

/// return raw attachment by index
async fn attachment_handler(
Path((id, index)): Path<(Uuid, usize)>,
Extension(state): Extension<Arc<AppState>>,
) -> Result<Response, StatusCode> {
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<Uuid>,
Extension(state): Extension<Arc<AppState>>,
) -> Result<Response, StatusCode> {
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)
Expand Down Expand Up @@ -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()
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
41 changes: 21 additions & 20 deletions frontend/src/message_header.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand Down Expand Up @@ -51,30 +51,31 @@ pub fn view(props: &MessageHeaderProps) -> Html {
<td>{&message.subject}</td>
</tr>
<tr>
<th>
if message.envelope_recipients.len() > 1 {
{"Recipients: "}
} else {
{"Recipient: "}
}
</th>
<td>
<span class="recipients">
{for message.envelope_recipients.clone().into_iter().map(|addr| html_nested! {
<span class="email">{addr}</span>
})}
</span>
</td>
</tr>
<th>
if message.envelope_recipients.len() > 1 {
{"Recipients: "}
} else {
{"Recipient: "}
}
</th>
<td>
<span class="recipients">
{for message.envelope_recipients.clone().into_iter().map(|addr| html_nested! {
<span class="email">{addr}</span>
})}
</span>
</td>
</tr>
</tbody>
</table>
<div class="actions">
{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
href={format!("data:{};base64,{}", &a.mime, &a.content)}
href={url}
download={a.filename.clone()}
class={&a.mime.replace('/', "-")}
class={a.mime.replace('/', "-")}
>
{&a.filename}
<span class="size">{&a.size}</span>
Expand All @@ -84,7 +85,7 @@ pub fn view(props: &MessageHeaderProps) -> Html {
<button class="invert-body" onclick={Callback::from(|_| {
toggle_body_invert();
})}>
{"Invert body"}
{"Invert body"}
</button>
</div>
</>
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ pub struct Attachment {
pub content_id: Option<String>,
pub mime: String,
pub size: String,
pub content: String,
// pub content: String,
}

#[derive(Clone, PartialEq, Eq, Deserialize, Default)]
Expand All @@ -51,7 +51,7 @@ pub struct MailMessage {
pub text: String,
pub html: String,
pub attachments: Vec<Attachment>,
pub raw: String,
// pub raw: String,
pub headers: HashMap<String, String>,
pub envelope_from: String,
pub envelope_recipients: Vec<String>,
Expand Down
32 changes: 24 additions & 8 deletions frontend/src/view.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand All @@ -25,14 +23,18 @@ pub struct ViewMessageProps {
#[function_component(ViewMessage)]
pub fn view(props: &ViewMessageProps) -> Html {
let message: UseStateHandle<MailMessage> = use_state(Default::default);
let raw_content: UseStateHandle<Option<String>> = 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 {
Expand All @@ -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() {
Expand Down Expand Up @@ -121,7 +137,7 @@ pub fn view(props: &ViewMessageProps) -> Html {
</tbody>
</table>
} else if props.active_tab == Tab::Raw {
<pre>{raw}</pre>
<pre>{(*raw_content).clone().unwrap_or_default()}</pre>
}
</div>
</div>
Expand Down
49 changes: 28 additions & 21 deletions mailcrab/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ pub enum Action {

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AttachmentMetadata {
filename: String,
pub filename: String,
mime: String,
size: String,
}
Expand Down Expand Up @@ -92,6 +92,7 @@ pub struct Attachment {
content_id: Option<String>,
mime: String,
size: String,
#[serde(skip)]
content: String,
}

Expand Down Expand Up @@ -144,7 +145,8 @@ pub struct MailMessage {
headers: HashMap<String, String>,
text: String,
html: String,
attachments: Vec<Attachment>,
pub attachments: Vec<Attachment>,
#[serde(skip)]
raw: String,
pub envelope_from: String,
pub envelope_recipients: Vec<String>,
Expand All @@ -155,28 +157,33 @@ impl MailMessage {
self.opened = true;
}

pub fn render(&self) -> String {
pub fn raw_bytes(&self) -> Option<Vec<u8>> {
base64ct::Base64::decode_vec(&self.raw).ok()
}

pub fn attachment_content(&self, index: usize) -> Option<(String, Vec<u8>)> {
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
}
}

Expand Down