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