Compare commits

...

2 Commits

Author SHA1 Message Date
Manos Pitsidianakis 568472a2e7
Various features lumped together
Signed-off-by: Manos Pitsidianakis <manos@pitsidianak.is>
2024-04-26 13:39:03 +03:00
Manos Pitsidianakis 393446ea61
Rename workspace dirs to their actual crate names
Signed-off-by: Manos Pitsidianakis <manos@pitsidianak.is>
2024-02-13 09:53:24 +02:00
138 changed files with 332 additions and 211 deletions

View File

@ -1,12 +1,13 @@
[workspace]
resolver = "2"
members = [
"archive-http",
"cli",
"core",
"mailpot",
"mailpot-archives",
"mailpot-cli",
"mailpot-http",
"mailpot-tests",
"rest-http",
"web",
"mailpot-web",
]
[profile.release]

View File

@ -6,7 +6,7 @@ DJHTMLBIN = djhtml
BLACKBIN = black
PRINTF = /usr/bin/printf
HTML_FILES := $(shell find web/src/templates -type f -print0 | tr '\0' ' ')
HTML_FILES := $(shell find mailpot-web/src/templates -type f -print0 | tr '\0' ' ')
PY_FILES := $(shell find . -type f -name '*.py' -print0 | tr '\0' ' ')
.PHONY: check

View File

@ -18,7 +18,7 @@ path = "src/main.rs"
[dependencies]
chrono = { version = "^0.4" }
lazy_static = "^1.4"
mailpot = { version = "^0.1", path = "../core" }
mailpot = { version = "^0.1", path = "../mailpot" }
minijinja = { version = "0.31.0", features = ["source", ] }
percent-encoding = { version = "^2.1", optional = true }
serde = { version = "^1", features = ["derive", ] }

View File

@ -20,7 +20,7 @@ doc-scrape-examples = true
base64 = { version = "0.21" }
clap = { version = "^4.2", default-features = false, features = ["std", "derive", "cargo", "unicode", "help", "usage", "error-context", "suggestions"] }
log = "0.4"
mailpot = { version = "^0.1", path = "../core" }
mailpot = { version = "^0.1", path = "../mailpot" }
serde = { version = "^1", features = ["derive", ] }
serde_json = "^1"
stderrlog = { version = "^0.6" }
@ -35,5 +35,5 @@ tempfile = { version = "3.9" }
[build-dependencies]
clap = { version = "^4.2", default-features = false, features = ["std", "derive", "cargo", "unicode", "wrap_help", "help", "usage", "error-context", "suggestions"] }
clap_mangen = "0.2.10"
mailpot = { version = "^0.1", path = "../core" }
mailpot = { version = "^0.1", path = "../mailpot" }
stderrlog = { version = "^0.6" }

View File

@ -25,8 +25,8 @@ config = "0.13"
http = "0.2"
lazy_static = "1.4"
log = "0.4"
mailpot = { version = "^0.1", path = "../core" }
mailpot-web = { version = "^0.1", path = "../web" }
mailpot = { version = "^0.1", path = "../mailpot" }
mailpot-web = { version = "^0.1", path = "../mailpot-web" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
stderrlog = { version = "^0.6" }

View File

@ -13,7 +13,7 @@ publish = false
assert_cmd = "2"
log = "0.4"
mailin-embedded = { version = "0.7", features = ["rtls"] }
mailpot = { version = "^0.1", path = "../core" }
mailpot = { version = "^0.1", path = "../mailpot" }
predicates = "3"
stderrlog = { version = "^0.6" }
tempfile = { version = "3.9" }

View File

@ -33,7 +33,7 @@ eyre = { version = "0.6" }
http = "0.2"
indexmap = { version = "1.9" }
lazy_static = "^1.4"
mailpot = { version = "^0.1", path = "../core" }
mailpot = { version = "^0.1", path = "../mailpot" }
minijinja = { version = "0.31.0", features = ["source", ] }
percent-encoding = { version = "^2.1" }
rand = { version = "^0.8", features = ["min_const_gen"] }

View File

@ -92,7 +92,6 @@ pub mod typed_paths;
pub mod utils;
pub use auth::*;
pub use cal::{calendarize, *};
pub use help::*;
pub use lists::{
list, list_candidates, list_edit, list_edit_POST, list_post, list_post_eml, list_post_raw,

View File

@ -218,7 +218,7 @@ pub async fn list_post(
url: ListPath(list.id.to_string().into()).to_crumb(),
},
Crumb {
label: format!("{} {msg_id}", subject_ref).into(),
label: format!("{} <{}>", subject_ref, msg_id.as_str().strip_carets()).into(),
url: ListPostPath(list.id.to_string().into(), msg_id.to_string()).to_crumb(),
},
];

View File

@ -0,0 +1,83 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
//! Utils for templates with the [`minijinja`] crate.
pub use mailpot::StripCarets;
use super::*;
mod compressed;
mod filters;
mod objects;
pub use filters::*;
pub use objects::*;
lazy_static::lazy_static! {
pub static ref TEMPLATES: Environment<'static> = {
let mut env = Environment::new();
macro_rules! add {
(function $($id:ident),*$(,)?) => {
$(env.add_function(stringify!($id), $id);)*
};
(filter $($id:ident),*$(,)?) => {
$(env.add_filter(stringify!($id), $id);)*
}
}
add!(function calendarize,
strip_carets,
ensure_carets,
urlize,
url_encode,
heading,
topics,
login_path,
logout_path,
settings_path,
help_path,
list_path,
list_settings_path,
list_edit_path,
list_subscribers_path,
list_candidates_path,
list_post_path,
post_raw_path,
post_eml_path,
post_mbox_path
);
add!(filter pluralize);
// Load compressed templates. They are constructed in build.rs. See
// [ref:embed_templates]
let mut source = minijinja::Source::new();
for (name, bytes) in compressed::COMPRESSED {
let mut de_bytes = vec![];
zstd::stream::copy_decode(*bytes,&mut de_bytes).unwrap();
source.add_template(*name, String::from_utf8(de_bytes).unwrap()).unwrap();
}
env.set_source(source);
env.add_global("root_url_prefix", Value::from_safe_string( std::env::var("ROOT_URL_PREFIX").unwrap_or_default()));
env.add_global("public_url",Value::from_safe_string(std::env::var("PUBLIC_URL").unwrap_or_default()));
env.add_global("site_title", Value::from_safe_string(std::env::var("SITE_TITLE").unwrap_or_else(|_| "mailing list archive".to_string())));
env.add_global("site_subtitle", std::env::var("SITE_SUBTITLE").ok().map(Value::from_safe_string).unwrap_or_default());
env
};
}

View File

@ -17,4 +17,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
//[tag:embed_templates]
/// This is an array of all templates compressed for smaller binary size.
///
/// Compression happens at compile-time in the `build.rs` script.
pub const COMPRESSED: &[(&str, &[u8])] = include!("compressed.data");

View File

@ -21,199 +21,10 @@
use std::fmt::Write;
use mailpot::models::ListOwner;
pub use mailpot::StripCarets;
use super::*;
mod compressed;
lazy_static::lazy_static! {
pub static ref TEMPLATES: Environment<'static> = {
let mut env = Environment::new();
macro_rules! add {
(function $($id:ident),*$(,)?) => {
$(env.add_function(stringify!($id), $id);)*
};
(filter $($id:ident),*$(,)?) => {
$(env.add_filter(stringify!($id), $id);)*
}
}
add!(function calendarize,
strip_carets,
urlize,
heading,
topics,
login_path,
logout_path,
settings_path,
help_path,
list_path,
list_settings_path,
list_edit_path,
list_subscribers_path,
list_candidates_path,
list_post_path,
post_raw_path,
post_eml_path
);
add!(filter pluralize);
// Load compressed templates. They are constructed in build.rs. See
// [ref:embed_templates]
let mut source = minijinja::Source::new();
for (name, bytes) in compressed::COMPRESSED {
let mut de_bytes = vec![];
zstd::stream::copy_decode(*bytes,&mut de_bytes).unwrap();
source.add_template(*name, String::from_utf8(de_bytes).unwrap()).unwrap();
}
env.set_source(source);
env.add_global("root_url_prefix", Value::from_safe_string( std::env::var("ROOT_URL_PREFIX").unwrap_or_default()));
env.add_global("public_url",Value::from_safe_string(std::env::var("PUBLIC_URL").unwrap_or_default()));
env.add_global("site_title", Value::from_safe_string(std::env::var("SITE_TITLE").unwrap_or_else(|_| "mailing list archive".to_string())));
env.add_global("site_subtitle", std::env::var("SITE_SUBTITLE").ok().map(Value::from_safe_string).unwrap_or_default());
env
};
}
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)]
pub struct MailingList {
pub pk: i64,
pub name: String,
pub id: String,
pub address: String,
pub description: Option<String>,
pub topics: Vec<String>,
#[serde(serialize_with = "super::utils::to_safe_string_opt")]
pub archive_url: Option<String>,
pub inner: DbVal<mailpot::models::MailingList>,
#[serde(default)]
pub is_description_html_safe: bool,
}
impl MailingList {
/// Set whether it's safe to not escape the list's description field.
///
/// If anyone can display arbitrary html in the server, that's bad.
///
/// Note: uses `Borrow` so that it can use both `DbVal<ListOwner>` and
/// `ListOwner` slices.
pub fn set_safety<O: std::borrow::Borrow<ListOwner>>(
&mut self,
owners: &[O],
administrators: &[String],
) {
if owners.is_empty() || administrators.is_empty() {
return;
}
self.is_description_html_safe = owners
.iter()
.any(|o| administrators.contains(&o.borrow().address));
}
}
impl From<DbVal<mailpot::models::MailingList>> for MailingList {
fn from(val: DbVal<mailpot::models::MailingList>) -> Self {
let DbVal(
mailpot::models::MailingList {
pk,
name,
id,
address,
description,
topics,
archive_url,
},
_,
) = val.clone();
Self {
pk,
name,
id,
address,
description,
topics,
archive_url,
inner: val,
is_description_html_safe: false,
}
}
}
impl std::fmt::Display for MailingList {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
self.id.fmt(fmt)
}
}
impl Object for MailingList {
fn kind(&self) -> minijinja::value::ObjectKind {
minijinja::value::ObjectKind::Struct(self)
}
fn call_method(
&self,
_state: &minijinja::State,
name: &str,
_args: &[Value],
) -> std::result::Result<Value, Error> {
match name {
"subscription_mailto" => {
Ok(Value::from_serializable(&self.inner.subscription_mailto()))
}
"unsubscription_mailto" => Ok(Value::from_serializable(
&self.inner.unsubscription_mailto(),
)),
"topics" => topics_common(&self.topics),
_ => Err(Error::new(
minijinja::ErrorKind::UnknownMethod,
format!("object has no method named {name}"),
)),
}
}
}
impl minijinja::value::StructObject for MailingList {
fn get_field(&self, name: &str) -> Option<Value> {
match name {
"pk" => Some(Value::from_serializable(&self.pk)),
"name" => Some(Value::from_serializable(&self.name)),
"id" => Some(Value::from_serializable(&self.id)),
"address" => Some(Value::from_serializable(&self.address)),
"description" if self.is_description_html_safe => {
self.description.as_ref().map_or_else(
|| Some(Value::from_serializable(&self.description)),
|d| Some(Value::from_safe_string(d.clone())),
)
}
"description" => Some(Value::from_serializable(&self.description)),
"topics" => Some(Value::from_serializable(&self.topics)),
"archive_url" => Some(Value::from_serializable(&self.archive_url)),
"is_description_html_safe" => {
Some(Value::from_serializable(&self.is_description_html_safe))
}
_ => None,
}
}
fn static_fields(&self) -> Option<&'static [&'static str]> {
Some(
&[
"pk",
"name",
"id",
"address",
"description",
"topics",
"archive_url",
"is_description_html_safe",
][..],
)
}
}
/// Return a vector of weeks, with each week being a vector of 7 days and
/// corresponding sum of posts per day.
pub fn calendarize(
@ -438,6 +249,25 @@ pub fn strip_carets(_state: &minijinja::State, arg: Value) -> std::result::Resul
))
}
/// `ensure_carets` filter for [`minijinja`].
///
/// Makes sure message id value is surrounded by carets `[<>].
pub fn ensure_carets(_state: &minijinja::State, arg: Value) -> std::result::Result<Value, Error> {
Ok({
let s = arg.as_str().ok_or_else(|| {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
format!("argument to ensure_carets() is of type {}", arg.kind()),
)
})?;
if !s.trim().starts_with('<') && !s.ends_with('>') {
Value::from(format!("<{s}>"))
} else {
Value::from(s)
}
})
}
/// `urlize` filter for [`minijinja`].
///
/// Returns a safe string for use in `<a href=..` attributes.
@ -471,6 +301,24 @@ pub fn urlize(state: &minijinja::State, arg: Value) -> std::result::Result<Value
Ok(Value::from_safe_string(format!("{prefix}{arg}")))
}
pub fn url_encode(_state: &minijinja::State, arg: Value) -> std::result::Result<Value, Error> {
Ok(Value::from_safe_string(
utf8_percent_encode(
arg.as_str().ok_or_else(|| {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
format!(
"url_decode() argument is not a string but of type {}",
arg.kind()
),
)
})?,
crate::typed_paths::PATH_SEGMENT,
)
.to_string(),
))
}
/// Make an html heading: `h1, h2, h3` etc.
///
/// # Example
@ -603,7 +451,7 @@ pub fn topics(topics: Value) -> std::result::Result<Value, Error> {
topics_common(&topics)
}
pub(crate) fn topics_common(topics: &[String]) -> std::result::Result<Value, Error> {
pub fn topics_common(topics: &[String]) -> std::result::Result<Value, Error> {
let mut ul = String::new();
write!(&mut ul, r#"<ul class="tags">"#)?;
for topic in topics {
@ -628,6 +476,8 @@ pub(crate) fn topics_common(topics: &[String]) -> std::result::Result<Value, Err
#[cfg(test)]
mod tests {
use mailpot::models::ListOwner;
use super::*;
#[test]

View File

@ -0,0 +1,161 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
//! Utils for templates with the [`minijinja`] crate.
use mailpot::models::ListOwner;
use super::*;
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)]
pub struct MailingList {
pub pk: i64,
pub name: String,
pub id: String,
pub address: String,
pub description: Option<String>,
pub topics: Vec<String>,
#[serde(serialize_with = "super::utils::to_safe_string_opt")]
pub archive_url: Option<String>,
pub inner: DbVal<mailpot::models::MailingList>,
#[serde(default)]
pub is_description_html_safe: bool,
}
impl MailingList {
/// Set whether it's safe to not escape the list's description field.
///
/// If anyone can display arbitrary html in the server, that's bad.
///
/// Note: uses `Borrow` so that it can use both `DbVal<ListOwner>` and
/// `ListOwner` slices.
pub fn set_safety<O: std::borrow::Borrow<ListOwner>>(
&mut self,
owners: &[O],
administrators: &[String],
) {
if owners.is_empty() || administrators.is_empty() {
return;
}
self.is_description_html_safe = owners
.iter()
.any(|o| administrators.contains(&o.borrow().address));
}
}
impl From<DbVal<mailpot::models::MailingList>> for MailingList {
fn from(val: DbVal<mailpot::models::MailingList>) -> Self {
let DbVal(
mailpot::models::MailingList {
pk,
name,
id,
address,
description,
topics,
archive_url,
},
_,
) = val.clone();
Self {
pk,
name,
id,
address,
description,
topics,
archive_url,
inner: val,
is_description_html_safe: false,
}
}
}
impl std::fmt::Display for MailingList {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
self.id.fmt(fmt)
}
}
impl Object for MailingList {
fn kind(&self) -> minijinja::value::ObjectKind {
minijinja::value::ObjectKind::Struct(self)
}
fn call_method(
&self,
_state: &minijinja::State,
name: &str,
_args: &[Value],
) -> std::result::Result<Value, Error> {
match name {
"subscription_mailto" => {
Ok(Value::from_serializable(&self.inner.subscription_mailto()))
}
"unsubscription_mailto" => Ok(Value::from_serializable(
&self.inner.unsubscription_mailto(),
)),
"topics" => topics_common(&self.topics),
_ => Err(Error::new(
minijinja::ErrorKind::UnknownMethod,
format!("object has no method named {name}"),
)),
}
}
}
impl minijinja::value::StructObject for MailingList {
fn get_field(&self, name: &str) -> Option<Value> {
match name {
"pk" => Some(Value::from_serializable(&self.pk)),
"name" => Some(Value::from_serializable(&self.name)),
"id" => Some(Value::from_serializable(&self.id)),
"address" => Some(Value::from_serializable(&self.address)),
"description" if self.is_description_html_safe => {
self.description.as_ref().map_or_else(
|| Some(Value::from_serializable(&self.description)),
|d| Some(Value::from_safe_string(d.clone())),
)
}
"description" => Some(Value::from_serializable(&self.description)),
"topics" => Some(Value::from_serializable(&self.topics)),
"archive_url" => Some(Value::from_serializable(&self.archive_url)),
"is_description_html_safe" => {
Some(Value::from_serializable(&self.is_description_html_safe))
}
_ => None,
}
}
fn static_fields(&self) -> Option<&'static [&'static str]> {
Some(
&[
"pk",
"name",
"id",
"address",
"description",
"topics",
"archive_url",
"is_description_html_safe",
][..],
)
}
}

View File

@ -671,7 +671,7 @@
}
table.headers tr>th {
text-align: left;
text-align: right;
color: var(--text-faded);
}
@ -711,6 +711,7 @@
td.message-id,
span.message-id{
user-select: all;
color: var(--text-faded);
}
.message-id>a {

View File

@ -9,13 +9,25 @@
<th scope="row">From:</th>
<td><bdi>{{ post.address }}</bdi></td>
</tr>
<tr>
<th scope="row">To:</th>
<td><bdi>{% if post.to %}{{ post.to }}{% else %}{{ list.address }}{% endif %}</bdi></td>
</tr>
{% if post.cc %}
<tr>
<th scope="row">Cc:</th>
<td><bdi>{{ post.cc }}</bdi></td>
</tr>
{% endif %}
<tr>
<th scope="row">Date:</th>
<td class="faded">{{ post.datetime }}</td>
</tr>
<tr>
<th scope="row">Message-ID:</th>
<td class="faded message-id"><a href="{{ list_post_path(list.id, post.message_id) }}">{{ strip_carets(post.message_id) }}</a></td>
<td class="faded"><span class="message-id">{{ strip_carets(post.message_id) }}</span>
<a href="{{ list_post_path(list.id, post.message_id) }}">permalink</a> / <a href="{{ post_raw_path(list.id, post.message_id) }}" title="View raw content" type="text/plain">raw</a> / <a href="{{ post_eml_path(list.id, post.message_id) }}" title="Download as RFC 5322 format" type="message/rfc822" download>eml</a> / <a href="{{ post_mbox_path(list.id, post.message_id) }}" title="Download as an MBOX" type="application/mbox" download>mbox</a>
</td>
</tr>
{% if in_reply_to %}
<tr>
@ -29,11 +41,11 @@
<td>{% for r in references %}<span class="faded message-id"><a href="{{ list_post_path(list.id, r) }}">{{ r }}</a></span>{% endfor %}</td>
</tr>
{% endif %}
<tr>
<td colspan="2"><details class="reply-details"><summary>more …</summary><a href="{{ post_raw_path(list.id, post.message_id) }}">View raw</a> <a href="{{ post_eml_path(list.id, post.message_id) }}">Download as <code>eml</code> (RFC 5322 format)</a></details></td>
</tr>
</table>
<div class="post-body">
<pre {% if odd %}style="--background-secondary: var(--background-critical);" {% endif %}title="E-mail text content">{{ body|trim }}</pre>
</div>
<div class="post-reply-link">{# [ref:TODO] also reply to list email. #}
<a href="mailto:{{ url_encode(post.address) }}?In-Reply-To={{ url_encode(ensure_carets(post.message_id)) }}&amp;{% if post.cc %}Cc={{ url_encode(post.cc) }}&amp;{% endif %}Subject=Re%3A{{ url_encode(subject) }}">Reply</a>
</div>
</div>

View File

@ -94,6 +94,10 @@ pub struct ListPostRawPath(pub ListPathIdentifier, pub String);
#[typed_path("/list/:id/posts/:msgid/eml/")]
pub struct ListPostEmlPath(pub ListPathIdentifier, pub String);
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
#[typed_path("/list/:id/posts/:msgid/mbox/")]
pub struct ListPostMboxPath(pub ListPathIdentifier, pub String);
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
#[typed_path("/list/:id/edit/")]
pub struct ListEditPath(pub ListPathIdentifier);
@ -209,6 +213,7 @@ macro_rules! list_post_impl {
list_post_impl!(list_post_path, ListPostPath);
list_post_impl!(post_raw_path, ListPostRawPath);
list_post_impl!(post_eml_path, ListPostEmlPath);
list_post_impl!(post_mbox_path, ListPostMboxPath);
pub mod tsr {
use std::{borrow::Cow, convert::Infallible};

Some files were not shown because too many files have changed in this diff Show More