Skip to content

Commit 451d073

Browse files
committed
Add custom error types and SMTP throughput benchmarks
- Add error.rs with Error and SmtpError enums for type-safe error handling - Export error types from lib.rs - Add criterion benchmarks for SMTP throughput testing: - Single email send (simple and HTML) - Batch email sending (10, 50, 100 emails) - Concurrent connections (2, 4, 8 simultaneous) - Run with: cargo bench
1 parent b55c27f commit 451d073

File tree

10 files changed

+533
-3
lines changed

10 files changed

+533
-3
lines changed

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ urlencoding = "2"
4545
rustls = "0.23"
4646
chrono = "0.4"
4747
tempfile = "3"
48+
criterion = { version = "0.5", features = ["async_tokio"] }
49+
50+
[[bench]]
51+
name = "smtp_throughput"
52+
harness = false
4853

4954
[lints.clippy]
5055
pedantic = { level = "warn", priority = -1 }

benches/smtp_throughput.rs

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
//! SMTP throughput benchmarks.
2+
//!
3+
//! Run with: `cargo bench`
4+
5+
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
6+
use lettre::transport::smtp::client::Tls;
7+
use lettre::{Message, SmtpTransport, Transport};
8+
use std::time::Duration;
9+
use tokio::runtime::Runtime;
10+
11+
/// Start the SMTP sink server and return the address.
12+
async fn start_server() -> (smtp_sink::RunningServers, u16) {
13+
let opts = smtp_sink::SinkOptions {
14+
smtp_port: Some(0), // Use random available port
15+
http_port: Some(0),
16+
max: Some(10000),
17+
..Default::default()
18+
};
19+
let servers = smtp_sink::start_sink(opts).await.unwrap();
20+
let port = servers.smtp_addr.port();
21+
(servers, port)
22+
}
23+
24+
/// Send a single email synchronously.
25+
fn send_email(port: u16, subject: &str) {
26+
let email = Message::builder()
27+
.from("sender@example.com".parse().unwrap())
28+
.to("recipient@example.com".parse().unwrap())
29+
.subject(subject)
30+
.body("This is a test email body for benchmarking.".to_string())
31+
.unwrap();
32+
33+
let mailer = SmtpTransport::builder_dangerous("127.0.0.1")
34+
.port(port)
35+
.tls(Tls::None)
36+
.build();
37+
38+
mailer.send(&email).unwrap();
39+
}
40+
41+
/// Send a larger email with HTML content.
42+
fn send_large_email(port: u16, subject: &str) {
43+
let html_body = r#"
44+
<!DOCTYPE html>
45+
<html>
46+
<head><title>Test Email</title></head>
47+
<body>
48+
<h1>Hello from the benchmark!</h1>
49+
<p>This is a test email with HTML content for benchmarking purposes.</p>
50+
<ul>
51+
<li>Item 1</li>
52+
<li>Item 2</li>
53+
<li>Item 3</li>
54+
</ul>
55+
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor
56+
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
57+
exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
58+
</body>
59+
</html>
60+
"#;
61+
62+
let email = Message::builder()
63+
.from("sender@example.com".parse().unwrap())
64+
.to("recipient@example.com".parse().unwrap())
65+
.subject(subject)
66+
.header(lettre::message::header::ContentType::TEXT_HTML)
67+
.body(html_body.to_string())
68+
.unwrap();
69+
70+
let mailer = SmtpTransport::builder_dangerous("127.0.0.1")
71+
.port(port)
72+
.tls(Tls::None)
73+
.build();
74+
75+
mailer.send(&email).unwrap();
76+
}
77+
78+
fn benchmark_single_email(c: &mut Criterion) {
79+
let rt = Runtime::new().unwrap();
80+
let (servers, port) = rt.block_on(start_server());
81+
82+
let mut group = c.benchmark_group("smtp_single_email");
83+
group.throughput(Throughput::Elements(1));
84+
group.measurement_time(Duration::from_secs(10));
85+
86+
group.bench_function("send_simple_email", |b| {
87+
let mut i = 0;
88+
b.iter(|| {
89+
i += 1;
90+
send_email(port, &format!("Benchmark email {i}"));
91+
});
92+
});
93+
94+
group.bench_function("send_html_email", |b| {
95+
let mut i = 0;
96+
b.iter(|| {
97+
i += 1;
98+
send_large_email(port, &format!("Benchmark HTML email {i}"));
99+
});
100+
});
101+
102+
group.finish();
103+
rt.block_on(servers.stop());
104+
}
105+
106+
fn benchmark_batch_emails(c: &mut Criterion) {
107+
let rt = Runtime::new().unwrap();
108+
let (servers, port) = rt.block_on(start_server());
109+
110+
let mut group = c.benchmark_group("smtp_batch_emails");
111+
group.measurement_time(Duration::from_secs(15));
112+
113+
for batch_size in [10, 50, 100] {
114+
group.throughput(Throughput::Elements(batch_size as u64));
115+
group.bench_with_input(
116+
BenchmarkId::new("batch", batch_size),
117+
&batch_size,
118+
|b, &size| {
119+
let mut batch = 0;
120+
b.iter(|| {
121+
batch += 1;
122+
for i in 0..size {
123+
send_email(port, &format!("Batch {batch} email {i}"));
124+
}
125+
});
126+
},
127+
);
128+
}
129+
130+
group.finish();
131+
rt.block_on(servers.stop());
132+
}
133+
134+
fn benchmark_concurrent_connections(c: &mut Criterion) {
135+
let rt = Runtime::new().unwrap();
136+
let (servers, port) = rt.block_on(start_server());
137+
138+
let mut group = c.benchmark_group("smtp_concurrent");
139+
group.measurement_time(Duration::from_secs(15));
140+
141+
for num_connections in [2, 4, 8] {
142+
group.throughput(Throughput::Elements(num_connections as u64));
143+
group.bench_with_input(
144+
BenchmarkId::new("connections", num_connections),
145+
&num_connections,
146+
|b, &n| {
147+
let mut batch = 0;
148+
b.iter(|| {
149+
batch += 1;
150+
let handles: Vec<_> = (0..n)
151+
.map(|i| {
152+
let subject = format!("Concurrent batch {batch} conn {i}");
153+
std::thread::spawn(move || {
154+
send_email(port, &subject);
155+
})
156+
})
157+
.collect();
158+
159+
for handle in handles {
160+
handle.join().unwrap();
161+
}
162+
});
163+
},
164+
);
165+
}
166+
167+
group.finish();
168+
rt.block_on(servers.stop());
169+
}
170+
171+
criterion_group!(
172+
benches,
173+
benchmark_single_email,
174+
benchmark_batch_emails,
175+
benchmark_concurrent_connections,
176+
);
177+
criterion_main!(benches);

public/index.html

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,54 @@
422422
background: #fafafa;
423423
}
424424

425+
/* Attachments */
426+
.attachments-section {
427+
margin-top: 24px;
428+
padding-top: 16px;
429+
border-top: 1px solid var(--gmail-border);
430+
}
431+
.attachments-title {
432+
font-size: 14px;
433+
font-weight: 500;
434+
color: var(--gmail-gray);
435+
margin-bottom: 12px;
436+
}
437+
.attachments-list {
438+
display: flex;
439+
flex-wrap: wrap;
440+
gap: 8px;
441+
}
442+
.attachment-item {
443+
display: flex;
444+
align-items: center;
445+
gap: 8px;
446+
padding: 8px 12px;
447+
background: var(--gmail-light-gray);
448+
border-radius: 8px;
449+
text-decoration: none;
450+
color: #202124;
451+
font-size: 13px;
452+
transition: background 0.2s;
453+
}
454+
.attachment-item:hover {
455+
background: var(--gmail-border);
456+
}
457+
.attachment-item svg {
458+
width: 18px;
459+
height: 18px;
460+
fill: var(--gmail-gray);
461+
}
462+
.attachment-name {
463+
max-width: 200px;
464+
overflow: hidden;
465+
text-overflow: ellipsis;
466+
white-space: nowrap;
467+
}
468+
.attachment-size {
469+
color: var(--gmail-gray);
470+
font-size: 11px;
471+
}
472+
425473
/* Empty state */
426474
.empty-state {
427475
display: flex;
@@ -625,6 +673,31 @@
625673
const date = new Date(selectedEmail.date).toLocaleString();
626674
const initial = (selectedEmail.from || 'U')[0].toUpperCase();
627675

676+
// Replace cid: references in HTML with our API endpoints
677+
let htmlContent = selectedEmail.html || '';
678+
if (htmlContent && selectedEmail.id) {
679+
htmlContent = htmlContent.replace(/cid:([^"'\s>]+)/gi, (match, cid) => {
680+
return `/emails/${encodeURIComponent(selectedEmail.id)}/cid/${encodeURIComponent(cid)}`;
681+
});
682+
}
683+
684+
// Build attachments section
685+
const attachments = selectedEmail.attachments || [];
686+
const attachmentsHtml = attachments.length > 0 ? `
687+
<div class="attachments-section">
688+
<div class="attachments-title">Attachments (${attachments.length})</div>
689+
<div class="attachments-list">
690+
${attachments.map(att => `
691+
<a class="attachment-item" href="/emails/${encodeURIComponent(selectedEmail.id)}/attachments/${encodeURIComponent(att.filename)}" target="_blank" download>
692+
<svg viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg>
693+
<span class="attachment-name">${escapeHtml(att.filename)}</span>
694+
<span class="attachment-size">${formatSize(att.size)}</span>
695+
</a>
696+
`).join('')}
697+
</div>
698+
</div>
699+
` : '';
700+
628701
detailContent.innerHTML = `
629702
<h1 class="detail-subject">${subject}</h1>
630703
<div class="detail-header">
@@ -637,8 +710,9 @@ <h1 class="detail-subject">${subject}</h1>
637710
</div>
638711
<div class="detail-body">
639712
${selectedEmail.text ? `<pre>${escapeHtml(selectedEmail.text)}</pre>` : ''}
640-
${selectedEmail.html ? `<div class="detail-body-html">${selectedEmail.html}</div>` : ''}
713+
${htmlContent ? `<div class="detail-body-html">${htmlContent}</div>` : ''}
641714
</div>
715+
${attachmentsHtml}
642716
`;
643717

644718
listView.classList.add('hidden');
@@ -673,6 +747,14 @@ <h1 class="detail-subject">${subject}</h1>
673747
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });
674748
}
675749

750+
function formatSize(bytes) {
751+
if (bytes === 0) return '0 B';
752+
const k = 1024;
753+
const sizes = ['B', 'KB', 'MB', 'GB'];
754+
const i = Math.floor(Math.log(bytes) / Math.log(k));
755+
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
756+
}
757+
676758
function escapeHtml(s) {
677759
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
678760
}

0 commit comments

Comments
 (0)