Skip to content

Commit c8c843c

Browse files
authored
Merge pull request #5 from bangnokia/dev
Dev
2 parents 2ad96e9 + c1e103b commit c8c843c

File tree

22 files changed

+908
-255
lines changed

22 files changed

+908
-255
lines changed

index.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
77
<title>Vite + Vue + TS</title>
8+
<style>
9+
:root {
10+
font-size: 14px;
11+
}
12+
</style>
813
</head>
914
<body>
1015
<div id="app"></div>

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "vite-project",
33
"private": true,
4-
"version": "0.0.0",
4+
"version": "1.1.0",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

send-test-emails.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import nodemailer from 'nodemailer'
2+
3+
const host = process.env.SMTP_HOST ?? '127.0.0.1'
4+
const port = Number(process.env.SMTP_PORT ?? '1025')
5+
const delayMs = Number(process.env.SEED_DELAY_MS ?? '350')
6+
7+
const transporter = nodemailer.createTransport({
8+
host,
9+
port,
10+
secure: false,
11+
})
12+
13+
const emails = [
14+
// Emails from Alice
15+
{
16+
from: '"Alice Johnson" <alice@example.com>',
17+
to: 'inbox@blademail.test',
18+
subject: 'Welcome to Blade Mail',
19+
text: 'Welcome to Blade Mail. This is the plain text version.',
20+
html: '<h1>Welcome</h1><p>This is a <strong>test email</strong> for your local inbox.</p>',
21+
},
22+
{
23+
from: '"Alice Johnson" <alice@example.com>',
24+
to: 'inbox@blademail.test',
25+
subject: 'Project Update',
26+
text: 'The project is moving along nicely.',
27+
html: '<p>The project is moving along <strong>nicely</strong>.</p>',
28+
},
29+
{
30+
from: '"Alice Johnson" <alice@example.com>',
31+
to: 'inbox@blademail.test',
32+
subject: 'Meeting Notes',
33+
text: 'Here are the notes from our meeting.',
34+
html: '<ul><li>Point 1</li><li>Point 2</li></ul>',
35+
},
36+
37+
// Emails from Bob
38+
{
39+
from: '"Bob Smith" <bob@example.com>',
40+
to: 'inbox@blademail.test',
41+
subject: 'Invoice #1001',
42+
text: 'Please find attached invoice #1001.',
43+
html: '<p>Please find attached invoice #1001.</p>',
44+
},
45+
{
46+
from: '"Bob Smith" <bob@example.com>',
47+
to: 'inbox@blademail.test',
48+
subject: 'Question about the design',
49+
text: 'Can we change the color of the button?',
50+
html: '<p>Can we change the color of the button?</p>',
51+
},
52+
]
53+
54+
function sleep(ms) {
55+
return new Promise((resolve) => setTimeout(resolve, ms))
56+
}
57+
58+
async function sendSeedEmails() {
59+
console.log(`Seeding ${emails.length} test emails to smtp://${host}:${port}`)
60+
61+
for (const [index, email] of emails.entries()) {
62+
try {
63+
const info = await transporter.sendMail(email)
64+
console.log(`✓ ${index + 1}/${emails.length} ${email.subject}`)
65+
console.log(` messageId: ${info.messageId}`)
66+
} catch (error) {
67+
console.error(`✗ ${index + 1}/${emails.length} ${email.subject}`)
68+
console.error(` ${error instanceof Error ? error.message : String(error)}`)
69+
}
70+
71+
if (index < emails.length - 1) {
72+
await sleep(delayMs)
73+
}
74+
}
75+
76+
console.log('Done seeding test emails.')
77+
}
78+
79+
await sendSeedEmails()

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "app"
3-
version = "0.1.0"
3+
version = "1.1.0"
44
description = "Local debugging email app"
55
authors = [ "bangnokia" ]
66
license = "MIT"

src-tauri/src/main.rs

Lines changed: 162 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ use email_parser::mime::ContentType;
99
use email_parser::mime::Entity;
1010
use mailin_embedded::response::OK;
1111
use mailin_embedded::{Handler, Response, Server, SslConfig};
12-
use once_cell::sync::OnceCell;
12+
use once_cell::sync::{Lazy, OnceCell};
1313
use serde::Serialize;
14-
use std::sync::atomic::{AtomicBool, Ordering};
14+
use std::net::{TcpListener, TcpStream};
15+
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
16+
use std::sync::Mutex;
1517
use std::thread;
16-
use std::net::TcpListener;
18+
use std::time::Duration;
1719
use tauri::menu::MenuBuilder;
1820
use tauri::Emitter;
1921
use tauri::Manager;
@@ -53,52 +55,12 @@ impl Handler for MyHandler {
5355

5456
#[tauri::command]
5557
async fn start_server(address: Option<String>) -> Result<String, String> {
56-
if SERVER_RUNNING.swap(true, Ordering::SeqCst) {
57-
return Ok("SMTP server is already running.".into());
58-
}
59-
60-
let address = address.unwrap_or("127.0.0.1:1025".into());
61-
let listener = match TcpListener::bind(&address) {
62-
Ok(listener) => listener,
63-
Err(err) => {
64-
SERVER_RUNNING.store(false, Ordering::SeqCst);
65-
return Err(format!("Failed to bind SMTP server on {address}: {err}"));
66-
}
67-
};
68-
69-
let address_for_thread = address.clone();
70-
thread::spawn(move || {
71-
let mut server = Server::new(MyHandler::new());
72-
73-
if let Err(err) = server
74-
.with_name("blade mail")
75-
.with_tcp_listener(listener)
76-
.with_ssl(SslConfig::None)
77-
{
78-
eprintln!("Failed to configure SMTP server on {address_for_thread}: {err}");
79-
SERVER_RUNNING.store(false, Ordering::SeqCst);
80-
return;
81-
}
82-
83-
println!("SMTP server is starting on {address_for_thread}...");
84-
85-
if let Err(err) = server.serve() {
86-
eprintln!("SMTP server stopped with error on {address_for_thread}: {err}");
87-
}
88-
89-
SERVER_RUNNING.store(false, Ordering::SeqCst);
90-
});
91-
92-
Ok(format!("SMTP server started on {address}."))
58+
start_server_inner(address.unwrap_or("127.0.0.1:1025".into()))
9359
}
9460

9561
#[tauri::command]
9662
fn stop_server() -> String {
97-
if SERVER_RUNNING.load(Ordering::SeqCst) {
98-
"SMTP server stop is not implemented yet. The SMTP server is still running.".into()
99-
} else {
100-
"SMTP server is not running.".into()
101-
}
63+
stop_server_inner().unwrap_or_else(|err| err)
10264
}
10365

10466
// MailBox = (Name: String, EmailAddress: String)
@@ -172,6 +134,132 @@ fn collect_addresses(addresses: Option<&Vec<Address>>) -> Option<Vec<String>> {
172134
(!addresses.is_empty()).then_some(addresses)
173135
}
174136

137+
#[derive(Default)]
138+
struct ServerControl {
139+
generation: u64,
140+
address: String,
141+
listener: Option<TcpListener>,
142+
stop_requested: bool,
143+
}
144+
145+
static MAIN_WINDOW: OnceCell<WebviewWindow> = OnceCell::new();
146+
static SERVER_RUNNING: AtomicBool = AtomicBool::new(false);
147+
static SERVER_GENERATION: AtomicU64 = AtomicU64::new(0);
148+
static SERVER_CONTROL: Lazy<Mutex<ServerControl>> = Lazy::new(|| Mutex::new(ServerControl::default()));
149+
150+
fn start_server_inner(requested_address: String) -> Result<String, String> {
151+
let listener = TcpListener::bind(&requested_address)
152+
.map_err(|err| format!("Failed to bind SMTP server on {requested_address}: {err}"))?;
153+
154+
let address = listener
155+
.local_addr()
156+
.map(|addr| addr.to_string())
157+
.map_err(|err| format!("Failed to resolve SMTP server address: {err}"))?;
158+
let control_listener = listener
159+
.try_clone()
160+
.map_err(|err| format!("Failed to clone SMTP listener for {address}: {err}"))?;
161+
162+
let generation = {
163+
let mut control = SERVER_CONTROL.lock().unwrap();
164+
if control.listener.is_some() {
165+
return if control.stop_requested {
166+
Err("SMTP server is stopping. Please try again in a moment.".into())
167+
} else {
168+
Ok(format!("SMTP server is already running on {}.", control.address))
169+
};
170+
}
171+
172+
let generation = SERVER_GENERATION.fetch_add(1, Ordering::SeqCst) + 1;
173+
control.generation = generation;
174+
control.address = address.clone();
175+
control.listener = Some(control_listener);
176+
control.stop_requested = false;
177+
generation
178+
};
179+
180+
SERVER_RUNNING.store(true, Ordering::SeqCst);
181+
182+
let address_for_thread = address.clone();
183+
thread::spawn(move || run_server(listener, address_for_thread, generation));
184+
185+
Ok(format!("SMTP server started on {address}."))
186+
}
187+
188+
fn run_server(listener: TcpListener, address: String, generation: u64) {
189+
let mut server = Server::new(MyHandler::new());
190+
191+
if let Err(err) = server
192+
.with_name("blade mail")
193+
.with_tcp_listener(listener)
194+
.with_ssl(SslConfig::None)
195+
{
196+
eprintln!("Failed to configure SMTP server on {address}: {err}");
197+
clear_server_control(generation);
198+
return;
199+
}
200+
201+
println!("SMTP server is starting on {address}...");
202+
203+
match server.serve() {
204+
Ok(_) => println!("SMTP server stopped on {address}."),
205+
Err(err) => {
206+
if is_stop_requested(generation) {
207+
println!("SMTP server stopped on {address}.");
208+
} else {
209+
eprintln!("SMTP server stopped with error on {address}: {err}");
210+
}
211+
}
212+
}
213+
214+
clear_server_control(generation);
215+
}
216+
217+
fn stop_server_inner() -> Result<String, String> {
218+
let address = {
219+
let mut control = SERVER_CONTROL.lock().unwrap();
220+
if control.listener.is_none() {
221+
return Ok("SMTP server is not running.".into());
222+
}
223+
224+
control.stop_requested = true;
225+
let listener = control
226+
.listener
227+
.as_ref()
228+
.ok_or_else(|| "SMTP server is not running.".to_string())?;
229+
listener
230+
.set_nonblocking(true)
231+
.map_err(|err| format!("Failed to prepare SMTP shutdown on {}: {err}", control.address))?;
232+
control.address.clone()
233+
};
234+
235+
let _ = TcpStream::connect(&address);
236+
237+
for _ in 0..50 {
238+
if !SERVER_RUNNING.load(Ordering::SeqCst) {
239+
return Ok(format!("SMTP server stopped on {address}."));
240+
}
241+
242+
thread::sleep(Duration::from_millis(20));
243+
}
244+
245+
Err(format!("Timed out stopping SMTP server on {address}."))
246+
}
247+
248+
fn is_stop_requested(generation: u64) -> bool {
249+
let control = SERVER_CONTROL.lock().unwrap();
250+
control.generation == generation && control.stop_requested
251+
}
252+
253+
fn clear_server_control(generation: u64) {
254+
let mut control = SERVER_CONTROL.lock().unwrap();
255+
if control.generation == generation {
256+
control.listener = None;
257+
control.address.clear();
258+
control.stop_requested = false;
259+
SERVER_RUNNING.store(false, Ordering::SeqCst);
260+
}
261+
}
262+
175263
fn parse(raw: String) -> EmailPayload {
176264
let mut payload = EmailPayload::empty(raw);
177265

@@ -305,9 +393,6 @@ fn parse(raw: String) -> EmailPayload {
305393
payload
306394
}
307395

308-
static MAIN_WINDOW: OnceCell<WebviewWindow> = OnceCell::new();
309-
static SERVER_RUNNING: AtomicBool = AtomicBool::new(false);
310-
311396
fn main() {
312397
tauri::Builder::default()
313398
.plugin(tauri_plugin_fs::init())
@@ -341,7 +426,10 @@ fn main() {
341426

342427
#[cfg(test)]
343428
mod tests {
344-
use super::parse;
429+
use super::{parse, start_server_inner, stop_server_inner, SERVER_CONTROL, SERVER_RUNNING};
430+
use std::net::TcpListener;
431+
use std::thread;
432+
use std::time::Duration;
345433

346434
#[test]
347435
fn parses_plain_text_email_without_panicking() {
@@ -376,4 +464,29 @@ mod tests {
376464
assert!(payload.subject.is_empty());
377465
assert!(payload.from.is_empty());
378466
}
467+
468+
#[test]
469+
fn start_and_stop_server_releases_the_port() {
470+
let started = start_server_inner("127.0.0.1:0".to_string()).unwrap();
471+
assert!(started.contains("SMTP server started on 127.0.0.1:"));
472+
473+
let address = {
474+
let control = SERVER_CONTROL.lock().unwrap();
475+
control.address.clone()
476+
};
477+
478+
for _ in 0..50 {
479+
if SERVER_RUNNING.load(std::sync::atomic::Ordering::SeqCst) {
480+
break;
481+
}
482+
483+
thread::sleep(Duration::from_millis(10));
484+
}
485+
486+
let stopped = stop_server_inner().unwrap();
487+
assert_eq!(stopped, format!("SMTP server stopped on {address}."));
488+
assert!(!SERVER_RUNNING.load(std::sync::atomic::Ordering::SeqCst));
489+
490+
TcpListener::bind(&address).expect("port should be released after stopping the server");
491+
}
379492
}

src-tauri/tauri.conf.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
},
4343
"productName": "Blade Mail",
4444
"mainBinaryName": "Blade Mail",
45-
"version": "1.0.9",
45+
"version": "1.1.0",
4646
"identifier": "com.blademail.app",
4747
"plugins": {
4848
"updater": {
@@ -60,7 +60,7 @@
6060
{
6161
"title": "Blade Mail",
6262
"width": 1000,
63-
"height": 600,
63+
"height": 700,
6464
"resizable": true,
6565
"fullscreen": false,
6666
"useHttpsScheme": true

0 commit comments

Comments
 (0)