Initial commit
This commit is contained in:
commit
4ac8229ddc
|
@ -0,0 +1,3 @@
|
||||||
|
target/
|
||||||
|
.svelte-kit/
|
||||||
|
node_modules/
|
File diff suppressed because it is too large
Load Diff
|
@ -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"] }
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
@ -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");
|
||||||
|
}
|
|
@ -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),
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
edition = "2021"
|
|
@ -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(())
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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");
|
||||||
|
}
|
|
@ -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"))
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
/build
|
||||||
|
/.svelte-kit
|
||||||
|
/package
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
|
@ -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
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
||||||
|
}
|
|
@ -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 {}
|
||||||
|
}
|
|
@ -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>
|
|
@ -0,0 +1 @@
|
||||||
|
export let prerender = true
|
|
@ -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>
|
|
@ -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>
|
Binary file not shown.
After Width: | Height: | Size: 1.5 KiB |
|
@ -0,0 +1,12 @@
|
||||||
|
import adapter from '@sveltejs/adapter-static';
|
||||||
|
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
kit: {
|
||||||
|
adapter: adapter()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
|
||||||
|
/** @type {import('vite').UserConfig} */
|
||||||
|
const config = {
|
||||||
|
plugins: [sveltekit()]
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
Loading…
Reference in New Issue