Skip to content
Merged
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
422 changes: 162 additions & 260 deletions backend/Cargo.lock

Large diffs are not rendered by default.

79 changes: 74 additions & 5 deletions backend/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ async fn test_receive_messages() -> Result<Vec<Response>, Box<dyn std::error::Er
}

#[tokio::test]
async fn receive_message() {
async fn functional() {
let join = tokio::task::spawn(run());

// wait for mailcrab to startup
Expand Down Expand Up @@ -148,13 +148,10 @@ async fn receive_message() {
let mut sorted_messages = vec![];
for id in &responses {
if let Some(message) = messages.iter().find(|m| m.id.to_string() == *id) {
sorted_messages.push(message);
sorted_messages.push(message.clone());
}
}

// stop the server
join.abort();

assert_eq!(sorted_messages.len(), 3);
assert!(sorted_messages[0].has_html);
assert!(sorted_messages[0].has_plain);
Expand All @@ -167,6 +164,53 @@ async fn receive_message() {
assert!(!sorted_messages[2].has_html);
assert!(sorted_messages[2].has_plain);
assert_eq!(sorted_messages[2].attachments.len(), 1);

// send a large attachment and verify it can be downloaded via the URL endpoint
const SIZE: usize = 75 * 1024 * 1024; // 75 MiB
send_large_file(SIZE).await.expect("send failed");

let mut large_meta = None;
for _ in 0..300 {
let messages = get_messages_metadata().await.unwrap();
if let Some(m) = messages
.into_iter()
.find(|m| m.attachments.iter().any(|a| a.filename == "large.bin"))
{
large_meta = Some(m);
break;
}
sleep(Duration::from_millis(100)).await;
}
let meta = large_meta.expect("large attachment message not received within timeout");

assert_eq!(meta.attachments.len(), 1);
assert_eq!(meta.attachments[0].filename, "large.bin");

let http_port: u16 = parse_env_var("HTTP_PORT", 1080);
let client = Client::builder()
.timeout(Duration::from_secs(60))
.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);

let expected: Vec<u8> = (0..SIZE).map(|i| (i % 251) as u8).collect();
assert_eq!(attachment_bytes.as_ref(), expected.as_slice());

// stop the server
join.abort();
}

#[tokio::test]
Expand Down Expand Up @@ -207,3 +251,28 @@ 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("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?)
}
52 changes: 51 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,51 @@ 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 (filename, mime, bytes) = message
.attachment_content(index)
.ok_or(StatusCode::NOT_FOUND)?;
let disposition = format!(
"attachment; filename=\"{}\"",
filename.replace('\\', "\\\\").replace('"', "\\\"")
);
let len = bytes.len();
Ok(Response::builder()
.header(header::CONTENT_TYPE, mime)
.header(header::CONTENT_LENGTH, len)
.header(header::CONTENT_DISPOSITION, disposition)
.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().unwrap_or_default();
let len = bytes.len();
Ok(Response::builder()
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.header(header::CONTENT_LENGTH, len)
.body(Body::from(bytes))
.unwrap())
}

async fn not_found() -> Response {
Response::builder()
.status(StatusCode::NOT_FOUND)
Expand Down Expand Up @@ -248,6 +293,11 @@ 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
Loading
Loading