Initial commit

This commit is contained in:
Joe Bellus 2022-09-28 00:09:57 -04:00
commit 4ac8229ddc
24 changed files with 6537 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
target/
.svelte-kit/
node_modules/

1600
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

23
Cargo.toml Normal file
View File

@ -0,0 +1,23 @@
[package]
name = "mailspy"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1.0.65"
serde = { version = "1.0.145", features = ["derive"] }
serde_json = "1.0.85"
tokio = { version = "1.21.1", features = ["full"] }
bytes = "1.2.1"
lettre = "0.10.1"
poem = { version = "1.3.44", features = ["compression", "embed"] }
rust-embed = "6.4.1"
tracing = "0.1.36"
tracing-subscriber = "0.3.15"
mailparse = "0.13.8"
clap = { version = "3.2.22", features = ["derive"] }

62
README.org Normal file
View File

@ -0,0 +1,62 @@
* Mailspy
Mailspy is a mock SMTP server for used during development of applications that send email. Mailspy will serve a compliant, mock SMTP server that an application can connect to, while in development mode. It will accept emails and display them in a web interface that it serves.
** Installation
Mailspy is available in binary distribution for 64-bit linux systems.
#+begin_src sh
curl https://objects.5sigma.io/public/conductor.tar.gz | tar -xz
#+end_src
* Usage
Execute the mailspy binary:
#+begin_src sh
./mailspy
#+end_src
Then set your application to use the following smtp settings:
*SMTP Server*: localhost
*Port*: 7778
*Username*: Any value
*Password*: Any value
*TLS*: None/Unsecured
*Authorization Type*: Basic Auth
View emails in the web interface served at:
http://localhost:7777
** Command line switches
- The SMTP port can be can be configured with -s <port>
- The HTTP port for the web interface can be configured with -h <port>
* Development
The core application Rust application is located in /src folder. This contains both the SMTP and HTTP servers. The web interface is located in /ui and is a Svelte application.
The application will automatically bundle the svelte application within the binary during build.
** Setting up development:
#+begin_src sh
git clone https://git.5sigma.io/mailspy/mailspy.git
cd mailspy
cd ui
npm install
#+end_src
** Building
#+begin_src sh
cd ui
npm run build
cd ..
cargo build --release
#+end_src

49
examples/html.rs Normal file
View File

@ -0,0 +1,49 @@
use lettre::{
message::{header, MultiPart, SinglePart},
Message, SmtpTransport, Transport,
};
fn main() {
// The html we want to send.
let html = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello from Lettre!</title>
</head>
<body>
<div style="display: flex; flex-direction: column; align-items: center;">
<h2 style="font-family: Arial, Helvetica, sans-serif;">Hello from Lettre!</h2>
<h4 style="font-family: Arial, Helvetica, sans-serif;">A mailer library for Rust</h4>
</div>
</body>
</html>"#;
// Build the message.
let email = Message::builder()
.from("NoBody <nobody@domain.tld>".parse().unwrap())
.to("Hei <hei@domain.tld>".parse().unwrap())
.subject("Hello from Lettre!")
.multipart(
MultiPart::alternative() // This is composed of two parts.
.singlepart(
SinglePart::builder()
.header(header::ContentType::TEXT_PLAIN)
.body(String::from("Hello from Lettre! A mailer library for Rust")), // Every message should have a plain text fallback.
)
.singlepart(
SinglePart::builder()
.header(header::ContentType::TEXT_HTML)
.body(String::from(html)),
),
)
.expect("failed to build email");
let mailer = SmtpTransport::builder_dangerous("localhost")
.port(7778)
.build();
// Store the message when you're ready.
mailer.send(&email).expect("failed to deliver message");
}

22
examples/simple.rs Normal file
View File

@ -0,0 +1,22 @@
use lettre::{Message, SmtpTransport, Transport};
fn main() {
let email = Message::builder()
.from("NoBody <nobody@domain.tld>".parse().unwrap())
.reply_to("Yuin <yuin@domain.tld>".parse().unwrap())
.to("Hei <hei@domain.tld>".parse().unwrap())
.subject("Happy new year")
.body(String::from("Be happy!"))
.unwrap();
// Open a remote connection to gmail
let mailer = SmtpTransport::builder_dangerous("localhost")
.port(7778)
.build();
// Send the email
match mailer.send(&email) {
Ok(_) => println!("Email sent successfully!"),
Err(e) => panic!("Could not send email: {:?}", e),
}
}

1
rustfmt.toml Normal file
View File

@ -0,0 +1 @@
edition = "2021"

42
src/http.rs Normal file
View File

@ -0,0 +1,42 @@
use crate::mail::{Mail, Mailbox};
use poem::{
endpoint::{EmbeddedFileEndpoint, EmbeddedFilesEndpoint},
get, handler,
listener::TcpListener,
middleware::AddData,
web::{Data, Json},
EndpointExt, Route, Server,
};
use rust_embed::RustEmbed;
#[derive(RustEmbed)]
#[folder = "ui/build"]
pub struct Files;
#[handler]
async fn messages(mailbox: Data<&Mailbox>) -> Json<Vec<Mail>> {
Json(mailbox.all().await)
}
#[handler]
async fn clear(mailbox: Data<&Mailbox>) -> String {
mailbox.clear().await;
"OK".to_string()
}
pub async fn server(mailbox: Mailbox, port: u16) -> anyhow::Result<()> {
tokio::spawn(async move {
let app = Route::new()
.at("/messages", get(messages))
.at("/", EmbeddedFileEndpoint::<Files>::new("index.html"))
.nest("/", EmbeddedFilesEndpoint::<Files>::new())
.with(AddData::new(mailbox));
if let Err(e) = Server::new(TcpListener::bind(format!("localhost:{}", port)))
.run(app)
.await
{
tracing::error!("Webserver error: {}", e);
}
});
Ok(())
}

43
src/mail.rs Normal file
View File

@ -0,0 +1,43 @@
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::Mutex;
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Mail {
pub from: String,
pub to: String,
pub date: String,
pub reply_to: String,
pub subject: String,
pub body: Vec<MailPart>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct MailPart {
pub content_type: String,
pub data: String,
}
#[derive(Debug, Clone)]
pub struct Mailbox(Arc<Mutex<Vec<Mail>>>);
impl Mailbox {
pub fn new() -> Self {
Self(Arc::new(Mutex::new(vec![])))
}
pub async fn store(&self, mail: Mail) {
let mut inner = self.0.lock().await;
inner.push(mail);
tracing::info!("New message stored");
}
pub async fn clear(&self) {
let mut inner = self.0.lock().await;
inner.clear();
}
pub async fn all(&self) -> Vec<Mail> {
let inner = self.0.lock().await;
inner.clone()
}
}

29
src/main.rs Normal file
View File

@ -0,0 +1,29 @@
mod http;
mod mail;
mod smtp;
use clap::Parser;
#[derive(Parser, Debug)]
#[clap(author, version, about, long_about = None)]
struct Args {
#[clap(short, long, value_parser, default_value = "7777")]
http_port: u16,
#[clap(short, long, value_parser, default_value = "7778")]
smtp_port: u16,
}
#[tokio::main]
async fn main() {
let args = Args::parse();
tracing_subscriber::fmt::init();
let mailbox = mail::Mailbox::new();
smtp::server(mailbox.clone(), args.smtp_port).await.unwrap();
http::server(mailbox.clone(), args.http_port).await.unwrap();
tokio::signal::ctrl_c()
.await
.expect("failed to listen for event");
}

267
src/smtp.rs Normal file
View File

@ -0,0 +1,267 @@
use bytes::{Buf, BytesMut};
use mailparse::MailHeaderMap;
use std::fmt::Write as _;
use std::io::Cursor;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt, BufWriter},
net::{TcpListener, TcpStream},
};
use crate::mail::{Mail, MailPart, Mailbox};
#[derive(PartialEq, Eq)]
pub enum ConnectionState {
Commands,
Data,
}
#[derive(Debug)]
pub enum Frame {
Header,
Raw(String),
Ehlo(String),
From(String),
To(String),
Ok(String),
DataStart,
DataEnd,
Quit,
StartMailInput,
Close,
}
impl Frame {
pub fn check(buf: &mut Cursor<&[u8]>) -> anyhow::Result<()> {
get_line(buf)?;
Ok(())
}
pub fn parse(buf: &mut Cursor<&[u8]>) -> anyhow::Result<Frame> {
let line = get_line(buf)?.to_vec();
let string = String::from_utf8(line)?;
if string.to_lowercase().starts_with("ehlo") {
return Ok(Frame::Ehlo(string[3..].to_string()));
}
if string.to_lowercase().starts_with("mail from:") {
return Ok(Frame::From(string[9..].to_string()));
}
if string.to_lowercase().starts_with("rcpt to:") {
return Ok(Frame::To(string[8..].to_string()));
}
if string.to_lowercase().starts_with("data") {
return Ok(Frame::DataStart);
}
if string.to_lowercase().starts_with("quit") {
return Ok(Frame::Quit);
}
if string.to_lowercase().trim().starts_with('.') {
return Ok(Frame::DataEnd);
}
Ok(Frame::Raw(string))
}
}
pub struct Connection {
stream: BufWriter<TcpStream>,
buffer: BytesMut,
mail_from: String,
rcpt_to: String,
data: String,
state: ConnectionState,
}
impl Connection {
pub fn new(socket: TcpStream) -> Connection {
Connection {
stream: BufWriter::new(socket),
buffer: BytesMut::with_capacity(4 * 1024),
state: ConnectionState::Commands,
mail_from: String::default(),
rcpt_to: String::default(),
data: String::default(),
}
}
pub async fn read_frame(&mut self) -> anyhow::Result<Option<Frame>> {
loop {
if let Some(frame) = self.parse_frame()? {
return Ok(Some(frame));
}
if 0 == self.stream.read_buf(&mut self.buffer).await? {
if self.buffer.is_empty() {
return Ok(None);
} else {
return Err(anyhow::anyhow!("Connection reset by peer"));
}
}
}
}
async fn write_frame(&mut self, frame: &Frame) -> anyhow::Result<()> {
match frame {
Frame::Raw(val) => {
self.stream.write_all(val.as_bytes()).await?;
self.stream.write_all(b"\r\n").await?;
}
Frame::Ok(val) => {
self.stream.write_all(b"250 ").await?;
self.stream.write_all(val.as_bytes()).await?;
self.stream.write_all(b"\r\n").await?;
}
Frame::Close => {
self.stream.write_all(b"221 Closing connection").await?;
self.stream.write_all(b"\r\n").await?;
}
Frame::Header => {
self.stream.write_all(b"220 Mailspy Test Server").await?;
self.stream.write_all(b"\r\n").await?;
}
Frame::StartMailInput => {
self.stream.write_all(b"354 Start mail input").await?;
self.stream.write_all(b"\r\n").await?;
}
_ => {}
}
self.stream.flush().await?;
Ok(())
}
fn parse_frame(&mut self) -> anyhow::Result<Option<Frame>> {
let mut buf = Cursor::new(&self.buffer[..]);
match Frame::check(&mut buf) {
Ok(_) => {
let len = buf.position() as usize;
buf.set_position(0);
let frame = Frame::parse(&mut buf)?;
self.buffer.advance(len);
Ok(Some(frame))
}
Err(_) => Ok(None),
}
}
}
pub async fn server(mailbox: Mailbox, port: u16) -> anyhow::Result<()> {
let addr = format!("127.0.0.1:{}", port);
let listener = TcpListener::bind(&addr).await?;
tracing::info!(port =? port, "SMTP Server running");
tokio::spawn(async move {
loop {
if let Ok((socket, _)) = listener.accept().await {
let mb = mailbox.clone();
tokio::spawn(async move {
if let Err(e) = process(socket, mb).await {
tracing::error!("Mail processing error: {}", e);
}
});
}
}
});
Ok(())
}
async fn process(socket: TcpStream, mailbox: Mailbox) -> anyhow::Result<()> {
let mut connection = Connection::new(socket);
connection.write_frame(&Frame::Header).await.unwrap();
loop {
if let Some(frame) = connection.read_frame().await.unwrap() {
tracing::info!("Frame read: {:?}", frame);
match frame {
Frame::Ehlo(_) => {
connection
.write_frame(&Frame::Ok("Good to go".to_string()))
.await?;
}
Frame::From(val) => {
connection.mail_from = val;
connection
.write_frame(&Frame::Ok("2.1.0 Sender OK".to_string()))
.await?;
}
Frame::To(val) => {
connection.rcpt_to = val;
connection
.write_frame(&Frame::Ok("2.1.5 Recipient OK".to_string()))
.await?;
}
Frame::DataStart => {
connection.write_frame(&Frame::StartMailInput).await?;
connection.state = ConnectionState::Data;
}
Frame::DataEnd => {
connection
.write_frame(&Frame::Ok("Mail sent".to_string()))
.await?;
let mut mail = Mail::default();
let data = connection.data.clone();
let msg = mailparse::parse_mail(data.as_bytes())?;
if msg.subparts.is_empty() {
mail.body = vec![MailPart {
content_type: msg
.headers
.get_first_value("Content-Type")
.unwrap_or_else(|| "text/plain".to_string()),
data: msg.get_body()?,
}];
} else {
mail.body = msg
.subparts
.iter()
.map(|part| {
part.get_body().map(|body| MailPart {
content_type: part
.headers
.get_first_value("Content-Type")
.unwrap_or_else(|| "text/plain".to_string()),
data: body,
})
})
.collect::<Result<Vec<MailPart>, _>>()?;
}
mail.subject = msg.headers.get_first_value("Subject").unwrap_or_default();
mail.from = msg
.headers
.get_first_value("From")
.unwrap_or_else(|| connection.mail_from.clone());
mail.to = msg
.headers
.get_first_value("To")
.unwrap_or_else(|| connection.rcpt_to.clone());
mail.date = msg.headers.get_first_value("Date").unwrap_or_default();
mailbox.store(mail).await;
connection.write_frame(&Frame::Close).await?;
break Ok(());
}
Frame::Raw(s) if connection.state == ConnectionState::Data => {
writeln!(connection.data, "{}", s)?;
}
_ => {
connection.write_frame(&Frame::Ok("OK".to_string())).await?;
}
}
}
}
}
/// Find a line
fn get_line<'a>(src: &mut Cursor<&'a [u8]>) -> anyhow::Result<&'a [u8]> {
if !src.has_remaining() {
return Err(anyhow::anyhow!("Incomplete"));
}
// Scan the bytes directly
let start = src.position() as usize;
// Scan to the second to last byte
let end = src.get_ref().len() - 1;
for i in start..end {
if src.get_ref()[i + 1] == b'\n' {
// We found a line, update the position to be *after* the \n
src.set_position((i + 2) as u64);
// Return the line
return Ok(&src.get_ref()[start..i + 1]);
}
}
Err(anyhow::anyhow!("Incomplete"))
}

8
ui/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example

1
ui/.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

17
ui/jsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias and https://kit.svelte.dev/docs/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

4091
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
ui/package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "ui",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-auto": "next",
"@sveltejs/adapter-static": "^1.0.0-next.39",
"@sveltejs/kit": "next",
"@sveltestack/svelte-query": "^1.6.0",
"axios": "^0.27.2",
"svelte": "^3.44.0",
"svelte-check": "^2.7.1",
"svelte-preprocess": "^4.10.7",
"svelte-select": "^4.4.7",
"typescript": "^4.7.4",
"vite": "^3.1.0"
},
"type": "module"
}

9
ui/src/app.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
// and what to do when importing types
declare namespace App {
// interface Locals {}
// interface PageData {}
// interface Error {}
// interface Platform {}
}

15
ui/src/app.html Normal file
View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width" />
%sveltekit.head%
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Raleway:wght@500;900&display=swap" rel="stylesheet">
</head>
<body>
<div>%sveltekit.body%</div>
</body>
</html>

1
ui/src/routes/+layout.js Normal file
View File

@ -0,0 +1 @@
export let prerender = true

View File

@ -0,0 +1,17 @@
<script>
import { QueryClient, QueryClientProvider } from "@sveltestack/svelte-query";
const queryClient = new QueryClient();
</script>
<QueryClientProvider client={queryClient}>
<div>
<slot />
</div>
</QueryClientProvider>
<style>
:global(html, body) {
padding: 0;
margin: 0;
}
</style>

191
ui/src/routes/+page.svelte Normal file
View File

@ -0,0 +1,191 @@
<script>
import axios from "axios";
import { useQuery } from "@sveltestack/svelte-query";
import Select from "svelte-select";
const listMessages = async () => {
const { data } = await axios.get(`/messages`);
return data;
};
const messageQuery = useQuery(["messages"], () => listMessages());
let selectedIndex;
let selectedPart;
const itemSelect = (idx) => () => {
selectedIndex = idx;
selectedPart = {
value: 0,
label: $messageQuery.data[selectedIndex].body[0].content_type,
};
};
const partSelect = ({ detail }) => {
selectedPart = detail;
};
$: isHtml = selectedPart?.label.toUpperCase().includes("HTML");
let partItems = [];
$: {
if (selectedIndex !== undefined) {
partItems = $messageQuery.data[selectedIndex].body.map((p, i) => ({
value: i,
label: p.content_type,
}));
}
}
</script>
<div class="header">mailspy</div>
<div class="message-list">
{#if !$messageQuery.isLoading}
{#each $messageQuery.data as msg, idx}
<div
class="mail-list-item"
on:click={itemSelect(idx)}
class:selected={selectedIndex === idx}
>
<div class="to">
{msg.to}
</div>
<div class="subject">
{msg.subject}
</div>
<div class="date">
{msg.date}
</div>
</div>
{/each}
{:else}
<div class="loader-container">
<div class="loader">
<div />
<div />
<div />
</div>
</div>
{/if}
</div>
{#if selectedIndex !== undefined}
<div class="part-select">
<Select
placeholder="Mail part"
isCreatable={false}
isClearable={false}
isSearchable={false}
items={partItems}
value={selectedPart}
on:select={partSelect}
/>
</div>
{/if}
<div class="body">
{#if selectedIndex !== undefined}
{#if isHtml}
{@html $messageQuery.data[selectedIndex].body[selectedPart.value]
.data}
{:else}
<pre>{$messageQuery.data[selectedIndex].body[selectedPart.value]
.data}</pre>
{/if}
{/if}
</div>
<style>
.message-list {
border-bottom: 1px solid #888;
background: #f5f5f5;
max-height: 200px;
overflow-y: scroll;
}
.part-select {
border-bottom: 1px solid #888;
}
.loader-container {
padding: 15px 25px;
}
.loader {
position: relative;
margin: auto auto;
width: 80px;
height: 80px;
}
.loader div {
display: inline-block;
position: absolute;
left: 8px;
width: 16px;
background: #888;
animation: loader 1.2s cubic-bezier(0, 0.5, 0.5, 1) infinite;
}
.loader div:nth-child(1) {
left: 8px;
animation-delay: -0.24s;
}
.loader div:nth-child(2) {
left: 32px;
animation-delay: -0.12s;
}
.loader div:nth-child(3) {
left: 56px;
animation-delay: 0;
}
@keyframes loader {
0% {
top: 8px;
height: 64px;
}
50%,
100% {
top: 24px;
height: 32px;
}
}
.mail-list-item {
transition: all 0.2s;
font-family: "Raleway", sans-serif;
font-size: 14px;
background: #fcfcfc;
border-bottom: 1px solid #ccc;
display: flex;
cursor: pointer;
font-weight: 500;
}
.mail-list-item:hover {
transition: all 0.2s;
background: #ddd;
}
.mail-list-item.selected {
transition: all 0.5s;
background: #ccddff;
}
.mail-list-item > * {
transition: all 0.2s;
padding: 8px 16px;
flex: 1;
}
.subject {
flex: 2;
}
.date {
text-align: right;
}
.body {
padding: 25px;
font-family: sans-serif;
}
.header {
font-family: "Raleway", sans-serif;
background: #222;
color: white;
font-weight: 900;
font-size: 24px;
padding: 14px 25px;
font-family: raleway;
}
pre {
padding: 0;
margin: 0;
}
</style>

BIN
ui/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

12
ui/svelte.config.js Normal file
View File

@ -0,0 +1,12 @@
import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter()
}
};
export default config;

8
ui/vite.config.js Normal file
View File

@ -0,0 +1,8 @@
import { sveltekit } from '@sveltejs/kit/vite';
/** @type {import('vite').UserConfig} */
const config = {
plugins: [sveltekit()]
};
export default config;