Compare commits

...

5 Commits

Author SHA1 Message Date
Jacob Kiers 054324cdd7 Split message readers into their own files
continuous-integration/drone/push Build is failing Details
Signed-off-by: Jacob Kiers <jacob@jacobkiers.net>
2023-06-10 10:04:22 +02:00
Jacob Kiers 47e0bd9012 Convert enum arguments to structs
Signed-off-by: Jacob Kiers <jacob@jacobkiers.net>
2023-06-10 10:04:22 +02:00
Jacob Kiers 0569591aa5 Add protection features to service definition
Signed-off-by: Jacob Kiers <jacob@jacobkiers.net>
2023-06-10 10:04:22 +02:00
Jacob Kiers ac51541c0c Fix some more clippy lints
Signed-off-by: Jacob Kiers <jacob@jacobkiers.net>
2023-06-10 10:04:22 +02:00
Jacob Kiers a3317592e1 Update all cargo dependencies
Signed-off-by: Jacob Kiers <jacob@jacobkiers.net>
2023-06-10 10:04:22 +02:00
10 changed files with 767 additions and 615 deletions

View File

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
* Added protection features to example systemd service file
## [0.2.3] - 2022-12-29
### Added

884
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -11,15 +11,13 @@ authors = [
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
atom_syndication = "^0.11.0"
base16ct = { version = "^0.1.0", features = [ "alloc" ] }
chrono = "^0.4"
clap = { version = "^4.0.22", features = [ "derive" ] }
imap = { version = "^2.4.1", default-features = false }
mail-parser = "^0.8.0"
rustls-connector = { version = "^0.16.1", default-features = false, features = [ "webpki-roots-certs", "quic" ] }
sha2 = "^0.10.2"
self_update = { version = "0.33.0", default-features = false, features = ["rustls"] }
[patch.crates-io]
atom_syndication = { git = "https://github.com/rust-syndication/atom", rev = "5cf8d161e5e5af7d93cca8d2c117b7af879a99b7" }
atom_syndication = "0.12.0"
base16ct = { version = "0.2.0", features = [ "alloc" ] }
chrono = "0.4.23"
clap = { version = "4.2.7", features = [ "derive" ] }
imap = { version = "3.0.0-alpha.10", default-features = false }
mail-parser = "0.8.2"
rustls-connector = { version = "0.17.0", default-features = false, features = [ "webpki-roots-certs" ] }
self_update = { version = "0.36.0", default-features = false, features = ["rustls"] }
sha2 = "0.10.6"
reqwest = { version = "0.11.14", default-features = false, features = ["rustls", "blocking"] }

View File

@ -1,6 +1,6 @@
use std::path::PathBuf;
use clap::{Parser, Subcommand};
use clap::{Args, Parser, Subcommand};
#[derive(Parser)]
#[clap(author, version, about, long_about = None)]
@ -9,35 +9,41 @@ pub(crate) struct Cli {
pub command: Command,
}
#[derive(Args)]
pub(crate) struct ImapSettings {
#[clap(short, long, value_parser)]
pub server: String,
#[clap(long, value_parser, default_value_t = 993)]
pub port: u16,
#[clap(short, long, value_parser)]
pub username: String,
#[clap(short, long, value_parser)]
pub password: String,
}
#[derive(Args)]
pub(crate) struct FeedBuilderSettings {
/// Host name hosting the feed
pub hostname: String,
/// Feed file
#[clap(value_parser, default_value = "output/feed.xml")]
pub filename: PathBuf,
/// Create an HTML file for each message
#[clap(short, long, value_parser, default_value_t = false)]
pub include_html: bool,
}
#[derive(Subcommand)]
pub(crate) enum Command {
/// Fetch emails from an IMAP server
FetchFromImap {
#[clap(short, long, value_parser)]
server: String,
#[clap(long, value_parser, default_value_t = 993)]
port: u16,
#[clap(short, long, value_parser)]
username: String,
#[clap(short, long, value_parser)]
password: String,
},
FetchFromImap(ImapSettings),
/// Fetch an email from a .eml file
FetchFromFile {
#[clap(value_parser)]
filename: PathBuf,
},
/// Build an ATOM feed containing the full contents of the email
BuildFeed {
/// Host name hosting the feed
hostname: String,
/// Feed file
#[clap(value_parser, default_value = "output/feed.xml")]
filename: PathBuf,
/// Create an HTML file for each message
#[clap(short, long, value_parser, default_value_t = false)]
include_html: bool,
},
BuildFeed(FeedBuilderSettings),
/// Exports the emails as HTML files
ExportHtml {
/// The directory in which the emails will be stored

View File

@ -11,8 +11,8 @@ use mail_parser::HeaderValue;
pub(crate) fn add_entry_to_feed(
feed: &mut Feed,
message: &Message,
processed_html: &String,
hostname: &String,
processed_html: &str,
hostname: &str,
include_html: bool,
) {
let parsed = message.get_parsed().unwrap();
@ -39,7 +39,7 @@ pub(crate) fn add_entry_to_feed(
uri: None,
},
title: parsed.subject().expect("Expected a subject").to_string(),
content: Some(processed_html.clone()),
content: Some(processed_html.to_owned()),
id: url.clone(),
published: Utc.timestamp_opt(date.to_timestamp(), 0).unwrap(),
url: match include_html {
@ -53,13 +53,13 @@ pub(crate) fn add_entry_to_feed(
}
pub(crate) fn build_atom_feed(hostname: &String, feed_file: &str) -> Feed {
let feed_url = format!("https://{}/{}", hostname, feed_file);
let feed_url = format!("https://{hostname}/{feed_file}");
FeedBuilder::default()
.title("JJKiers Newsletters")
.id(&feed_url)
.link(
LinkBuilder::default()
.href(format!("https://{}/", hostname))
.href(format!("https://{hostname}/"))
.rel("alternate".to_string())
.build(),
)

View File

@ -1,4 +1,4 @@
#[warn(missing_docs)]
#[deny(missing_docs)]
#[doc = include_str!("../README.md")]
mod cli;
mod command;
@ -29,24 +29,22 @@ fn main() -> Result<(), Box<dyn Error>> {
let data_directory = "data";
let result = match &cli.command {
cli::Command::FetchFromImap {
server,
port,
username,
password,
} => fetch_from_imap(
cli::Command::FetchFromImap(imap_settings) => fetch_from_imap(
data_directory,
server.to_owned(),
*port,
username.to_owned(),
password.to_owned(),
imap_settings.server.to_owned(),
imap_settings.port,
imap_settings.username.to_owned(),
imap_settings.password.to_owned(),
),
cli::Command::BuildFeed {
filename,
hostname,
include_html,
} => build_feed(filename, hostname, *include_html),
cli::Command::BuildFeed(settings) => build_feed(
&settings.filename,
&settings.hostname,
settings.include_html,
),
cli::Command::Update => command::update::self_update(),
_ => unimplemented!("This method is not yet implemented."),
};
@ -85,7 +83,7 @@ fn build_feed(
.to_str()
.expect("Feed path should be printable.");
let mut feed = feed::build_atom_feed(&hostname, feed_file);
let mut feed = feed::build_atom_feed(hostname, feed_file);
let mut reader = DataDirectoryMessageReader::new(Path::new("data").to_path_buf());
@ -111,10 +109,10 @@ fn build_feed(
if include_html {
let path: PathBuf = [dir, Path::new(&get_path(&parsed, &msg))].iter().collect();
write_file(&path, processed_html.as_bytes())?;
write_file(path, processed_html.as_bytes())?;
}
feed::add_entry_to_feed(&mut feed, &msg, &processed_html, &hostname, include_html);
feed::add_entry_to_feed(&mut feed, &msg, &processed_html, hostname, include_html);
}
if !feed.entries.is_empty() {
@ -143,7 +141,7 @@ fn fetch_from_imap(
) -> Result<(), Box<dyn Error>> {
create_directory(data_directory)?;
print!("Getting mail from {} for mailbox {}", server, username);
print!("Getting mail from {server} for mailbox {username}");
let mut reader = ImapReader::new(server, port, username, password);
@ -168,12 +166,9 @@ fn fetch_from_imap(
);
let path = get_path(&parsed, &msg);
let html_path: PathBuf = [
Path::new(data_directory),
Path::new(&format!("{}.eml", path)),
]
.iter()
.collect();
let html_path: PathBuf = [Path::new(data_directory), Path::new(&format!("{path}.eml"))]
.iter()
.collect();
println!("Storing to {}", &html_path.display());

View File

@ -1,165 +1,13 @@
use std::{
collections::HashMap,
error::Error,
fs::{read_dir, DirEntry},
net::TcpStream,
path::PathBuf,
vec::IntoIter,
};
use std::vec::IntoIter;
use imap::Session;
use rustls_connector::RustlsConnector;
pub(crate) mod data_directory;
pub(crate) mod imap;
pub(crate) use data_directory::DataDirectoryMessageReader;
pub(crate) use crate::message_reader::imap::ImapReader;
use crate::Message;
pub(crate) trait EmailReader {
fn read_rfc822_messages(&mut self) -> Box<IntoIter<Message>>;
}
pub(crate) struct DataDirectoryMessageReader {
path: PathBuf,
}
impl DataDirectoryMessageReader {
pub fn new(path: PathBuf) -> Self {
DataDirectoryMessageReader { path }
}
}
impl EmailReader for DataDirectoryMessageReader {
fn read_rfc822_messages(&mut self) -> Box<IntoIter<Message>> {
println!("Reading files in {}", &self.path.display());
let reader = match read_dir(&self.path) {
Ok(r) => r,
Err(e) => {
dbg!(e);
return Box::new(Vec::new().into_iter());
}
};
let items = reader
.filter(|i| i.is_ok())
.map(|i| i.unwrap() as DirEntry)
.filter(|d| match d.path().extension() {
Some(ext) => ext == "eml",
None => false,
})
.filter_map(|i| {
let uid = i
.path()
.file_stem()
.unwrap()
.to_owned()
.into_string()
.expect("Could not convert filename to string.")
.split('_')
.collect::<Vec<&str>>()[0]
.trim_start_matches('0')
.to_string();
if let Ok(data) = std::fs::read(i.path()) {
Some((uid, data))
} else {
None
}
})
.map(|i| Message::new(i.0, i.1));
let iter = items.collect::<Vec<Message>>().into_iter();
Box::new(iter)
}
}
pub struct ImapReader {
host: String,
port: u16,
username: String,
password: String,
}
impl ImapReader {
pub fn new(host: String, port: u16, username: String, password: String) -> Self {
ImapReader {
host,
port,
username,
password,
}
}
fn connect(&self) -> Result<HashMap<String, Vec<u8>>, Box<dyn Error>> {
let mut session = self.open_session()?;
session.examine("INBOX")?;
let items = match session.uid_search("ALL") {
Ok(i) => i,
Err(e) => return Err(Box::new(e)),
};
let mut msgs = HashMap::<String, Vec<u8>>::with_capacity(items.len());
//println!("# of messages: {}", &items.len());
for item in items {
let msg = session.uid_fetch(&item.to_string(), "(BODY.PEEK[] UID)")?;
let message = if let Some(m) = msg.iter().next() {
m
} else {
continue;
};
let body = message.body().expect("Message did not have a body.");
msgs.insert(item.to_string(), body.to_owned());
}
session.logout().expect("Could not log out");
Ok(msgs)
}
fn open_session(
&self,
) -> Result<
Session<
rustls_connector::rustls::StreamOwned<
rustls_connector::rustls::ClientConnection,
TcpStream,
>,
>,
Box<dyn Error + 'static>,
> {
let stream = TcpStream::connect((self.host.as_ref(), self.port))?;
let tls = RustlsConnector::new_with_webpki_roots_certs();
let tls_stream = tls.connect(&self.host, stream)?;
let client = imap::Client::new(tls_stream);
Ok(client
.login(&self.username, &self.password)
.map_err(|e| e.0)?)
}
}
impl EmailReader for ImapReader {
fn read_rfc822_messages(&mut self) -> Box<IntoIter<Message>> {
let msgs = match self.connect() {
Ok(m) => m,
Err(e) => {
dbg!(e);
return Box::new(Vec::new().into_iter());
}
};
let items = msgs
.iter()
.map(|i| Message::new(i.0.to_owned(), i.1.to_owned()));
let iter = items.collect::<Vec<Message>>().into_iter();
Box::new(iter)
}
}

View File

@ -0,0 +1,62 @@
use std::{
fs::{read_dir, DirEntry},
path::PathBuf,
vec::IntoIter,
};
use crate::message::Message;
use crate::message_reader::EmailReader;
pub(crate) struct DataDirectoryMessageReader {
path: PathBuf,
}
impl DataDirectoryMessageReader {
pub fn new(path: PathBuf) -> Self {
DataDirectoryMessageReader { path }
}
}
impl EmailReader for DataDirectoryMessageReader {
fn read_rfc822_messages(&mut self) -> Box<IntoIter<Message>> {
println!("Reading files in {}", &self.path.display());
let reader = match read_dir(&self.path) {
Ok(r) => r,
Err(e) => {
dbg!(e);
return Box::new(Vec::new().into_iter());
}
};
let items = reader
.filter(|i| i.is_ok())
.map(|i| i.unwrap() as DirEntry)
.filter(|d| match d.path().extension() {
Some(ext) => ext == "eml",
None => false,
})
.filter_map(|i| {
let uid = i
.path()
.file_stem()
.unwrap()
.to_owned()
.into_string()
.expect("Could not convert filename to string.")
.split('_')
.collect::<Vec<&str>>()[0]
.trim_start_matches('0')
.to_string();
if let Ok(data) = std::fs::read(i.path()) {
Some((uid, data))
} else {
None
}
})
.map(|i| Message::new(i.0, i.1));
let iter = items.collect::<Vec<Message>>().into_iter();
Box::new(iter)
}
}

104
src/message_reader/imap.rs Normal file
View File

@ -0,0 +1,104 @@
use std::{
collections::HashMap,
error::Error,
net::TcpStream,
vec::IntoIter,
};
use imap::Session;
use rustls_connector::RustlsConnector;
use crate::message::Message;
use crate::message_reader::EmailReader;
pub struct ImapReader {
host: String,
port: u16,
username: String,
password: String,
}
impl ImapReader {
pub fn new(host: String, port: u16, username: String, password: String) -> Self {
ImapReader {
host,
port,
username,
password,
}
}
fn connect(&self) -> Result<HashMap<String, Vec<u8>>, Box<dyn Error>> {
let mut session = self.open_session()?;
session.examine("INBOX")?;
let items = match session.uid_search("ALL") {
Ok(i) => i,
Err(e) => return Err(Box::new(e)),
};
let mut msgs = HashMap::<String, Vec<u8>>::with_capacity(items.len());
//println!("# of messages: {}", &items.len());
for item in items {
let msg = session.uid_fetch(&item.to_string(), "(BODY.PEEK[] UID)")?;
let message = if let Some(m) = msg.iter().next() {
m
} else {
continue;
};
let body = message.body().expect("Message did not have a body.");
msgs.insert(item.to_string(), body.to_owned());
}
session.logout().expect("Could not log out");
Ok(msgs)
}
fn open_session(
&self,
) -> Result<
Session<
rustls_connector::rustls::StreamOwned<
rustls_connector::rustls::ClientConnection,
TcpStream,
>,
>,
Box<dyn Error + 'static>,
> {
let stream = TcpStream::connect((self.host.as_ref(), self.port))?;
let tls = RustlsConnector::new_with_webpki_roots_certs();
let tls_stream = tls.connect(&self.host, stream)?;
let client = imap::Client::new(tls_stream);
Ok(client
.login(&self.username, &self.password)
.map_err(|e| e.0)?)
}
}
impl EmailReader for ImapReader {
fn read_rfc822_messages(&mut self) -> Box<IntoIter<Message>> {
let msgs = match self.connect() {
Ok(m) => m,
Err(e) => {
dbg!(e);
return Box::new(Vec::new().into_iter());
}
};
let items = msgs
.iter()
.map(|i| Message::new(i.0.to_owned(), i.1.to_owned()));
let iter = items.collect::<Vec<Message>>().into_iter();
Box::new(iter)
}
}

View File

@ -1,7 +1,42 @@
[Unit]
Description=Create newsletter feed
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=/home/n2w
WorkingDirectory=/home/n2w/n2w
ExecStart=/home/n2w/build-feed.sh
User=n2w
# Security
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
SystemCallFilter=@system-service
#SystemCallFilter=@basic-io @file-system @network-io mprotect
CapabilityBoundingSet=
NoNewPrivileges=yes
ProtectProc=invisible
RemoveIPC=yes
RestrictAddressFamilies=AF_INET AF_INET6
RestrictNamespaces=yes
PrivateUsers=yes
# ProtectHostname and ProcSubset=pid cannot go together
# see: https://github.com/systemd/systemd/pull/22203
# This is fixed in systemd v251
#ProtectHostname=yes
ProtectClock=yes
ProtectKernalTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
RestrictRealtime=yes
ProcSubset=pid
UMask=0077
SystemCallArchitectures=native
RestrictSUIDSGID=yes