web: add thread replies to post view

axum-login-upgrade
Manos Pitsidianakis 2023-04-27 21:18:09 +03:00
parent 3b3665b40c
commit 0e333af4e5
Signed by: Manos Pitsidianakis
GPG Key ID: 7729C7707F7E09D0
10 changed files with 416 additions and 37 deletions

View File

@ -79,6 +79,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
compressed.write_all(b"&[")?;
for (name, template_path) in templates {
let mut templ = OpenOptions::new()
.write(false)
@ -98,6 +99,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
compressed.write_all(format!("{:?}", compressed_bytes).as_bytes())?;
compressed.write_all(b"),")?;
}
compressed.write_all(b"]")?;
commit_sha();
Ok(())

View File

@ -47,13 +47,31 @@ pub async fn list(
.map(|user| db.list_subscription_by_address(list.pk, &user.address).ok());
let posts = db.list_posts(list.pk, None)?;
let post_map = posts
.iter()
.map(|p| (p.message_id.as_str(), p))
.collect::<IndexMap<&str, &mailpot::models::DbVal<mailpot::models::Post>>>();
let mut hist = months
.iter()
.map(|m| (m.to_string(), [0usize; 31]))
.collect::<HashMap<String, [usize; 31]>>();
let posts_ctx = posts
.iter()
.map(|post| {
let envelopes: Arc<std::sync::RwLock<HashMap<melib::EnvelopeHash, melib::Envelope>>> =
Default::default();
let mut env_lock = envelopes.write().unwrap();
for post in &posts {
let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None)
.expect("Could not parse mail");
env_lock.insert(envelope.hash(), envelope);
}
let mut threads: melib::Threads = melib::Threads::new(posts.len());
drop(env_lock);
threads.amend(&envelopes);
let roots = thread_roots(&envelopes, &mut threads);
let posts_ctx = roots
.into_iter()
.map(|(thread, length, _timestamp)| {
let post = &post_map[&thread.message_id.as_str()];
//2019-07-14T14:21:02
if let Some(day) = post.datetime.get(8..10).and_then(|d| d.parse::<u64>().ok()) {
hist.get_mut(&post.month_year).unwrap()[day.saturating_sub(1) as usize] += 1;
@ -70,7 +88,7 @@ pub async fn list(
{
subject_ref = subject_ref[2 + list.id.len()..].trim();
}
minijinja::context! {
let ret = minijinja::context! {
pk => post.pk,
list => post.list,
subject => subject_ref,
@ -79,8 +97,10 @@ pub async fn list(
message => post.message,
timestamp => post.timestamp,
datetime => post.datetime,
root_url_prefix => &state.root_url_prefix,
}
replies => length.saturating_sub(1),
last_active => thread.datetime,
};
ret
})
.collect::<Vec<_>>();
let crumbs = vec![
@ -123,7 +143,7 @@ pub async fn list_post(
auth: AuthContext,
State(state): State<Arc<AppState>>,
) -> Result<Html<String>, ResponseError> {
let db = Connection::open_db(state.conf.clone())?;
let db = Connection::open_db(state.conf.clone())?.trusted();
let Some(list) = (match id {
ListPathIdentifier::Pk(id) => db.list(id)?,
ListPathIdentifier::Id(id) => db.list_by_id(id)?,
@ -146,6 +166,7 @@ pub async fn list_post(
StatusCode::NOT_FOUND,
));
};
let thread = super::utils::thread_db(&db, list.pk, &post.message_id);
let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None)
.with_status(StatusCode::BAD_REQUEST)?;
let body = envelope.body_bytes(post.message.as_slice());
@ -190,6 +211,7 @@ pub async fn list_post(
message => post.message,
timestamp => post.timestamp,
datetime => post.datetime,
thread => thread,
root_url_prefix => &state.root_url_prefix,
current_user => auth.current_user,
user_context => user_context,
@ -485,3 +507,67 @@ pub enum SubscriptionPolicySettings {
Request,
Custom,
}
/// Raw post page.
pub async fn post_raw(
ListPostRawPath(id, msg_id): ListPostRawPath,
State(state): State<Arc<AppState>>,
) -> Result<String, ResponseError> {
let db = Connection::open_db(state.conf.clone())?.trusted();
let Some(list) = (match id {
ListPathIdentifier::Pk(id) => db.list(id)?,
ListPathIdentifier::Id(id) => db.list_by_id(id)?,
}) else {
return Err(ResponseError::new(
"List not found".to_string(),
StatusCode::NOT_FOUND,
));
};
let post = if let Some(post) = db.list_post_by_message_id(list.pk, &msg_id)? {
post
} else {
return Err(ResponseError::new(
format!("Post with Message-ID {} not found", msg_id),
StatusCode::NOT_FOUND,
));
};
Ok(String::from_utf8_lossy(&post.message).to_string())
}
/// .eml post page.
pub async fn post_eml(
ListPostEmlPath(id, msg_id): ListPostEmlPath,
State(state): State<Arc<AppState>>,
) -> Result<impl IntoResponse, ResponseError> {
let db = Connection::open_db(state.conf.clone())?.trusted();
let Some(list) = (match id {
ListPathIdentifier::Pk(id) => db.list(id)?,
ListPathIdentifier::Id(id) => db.list_by_id(id)?,
}) else {
return Err(ResponseError::new(
"List not found".to_string(),
StatusCode::NOT_FOUND,
));
};
let post = if let Some(post) = db.list_post_by_message_id(list.pk, &msg_id)? {
post
} else {
return Err(ResponseError::new(
format!("Post with Message-ID {} not found", msg_id),
StatusCode::NOT_FOUND,
));
};
let mut response = post.into_inner().message.into_response();
response.headers_mut().insert(
http::header::CONTENT_TYPE,
http::HeaderValue::from_static("application/octet-stream"),
);
response.headers_mut().insert(
http::header::CONTENT_DISPOSITION,
http::HeaderValue::try_from(format!("attachment; filename=\"{}.eml\"", msg_id)).unwrap(),
);
Ok(response)
}

View File

@ -62,6 +62,8 @@ async fn main() {
.route("/", get(root))
.typed_get(list)
.typed_get(list_post)
.typed_get(post_raw)
.typed_get(post_eml)
.typed_get(list_edit.layer(RequireAuth::login_with_role_or_redirect(
Role::User..,
Arc::clone(&login_url),

View File

@ -37,6 +37,7 @@ lazy_static::lazy_static! {
}
}
add!(function calendarize,
strip_carets,
login_path,
logout_path,
settings_path,
@ -44,7 +45,9 @@ lazy_static::lazy_static! {
list_path,
list_settings_path,
list_edit_path,
list_post_path
list_post_path,
post_raw_path,
post_eml_path
);
add!(filter pluralize);
#[cfg(not(feature = "zstd"))]
@ -381,3 +384,16 @@ pub fn pluralize(
(true, _, Some(suffix)) => suffix.into(),
})
}
pub fn strip_carets(_state: &minijinja::State, arg: Value) -> std::result::Result<Value, Error> {
Ok(Value::from(
arg.as_str()
.ok_or_else(|| {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
format!("argument to strip_carets() is of type {}", arg.kind()),
)
})?
.strip_carets(),
))
}

View File

@ -17,4 +17,4 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
pub const COMPRESSED: &[(&str, &[u8])] = &[include!("compressed.data")];
pub const COMPRESSED: &[(&str, &[u8])] = include!("compressed.data");

View File

@ -49,7 +49,7 @@
text-rendering:optimizeLegibility;
-webkit-font-smoothing:antialiased;
-moz-osx-font-smoothing:grayscale;
font-family:-apple-system,BlinkMacSystemFont,Roboto,Roboto Slab,Droid Serif,Segoe UI,system-ui,Arial,sans-serif;
font-family:var(--sans-serif-system-stack);
font-size:100%;
}
@ -149,9 +149,12 @@
}
:root {
--emoji-system-stack: 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--monospace-system-stack: /* apple */ ui-monospace, SFMono-Regular, Menlo, Monaco,
/* windows */ "Cascadia Mono", "Segoe UI Mono", Consolas,
/* free unixes */ "Liberation Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Courier New", monospace;
/* free unixes */ "DejaVu Sans Mono", "Liberation Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Courier New", monospace, var(--emoji-system-stack);
--sans-serif-system-stack:-apple-system,BlinkMacSystemFont,Roboto,Roboto Slab,Droid Serif,Segoe UI,system-ui,Arial,sans-serif, var(--emoji-system-stack);
--grotesque-system-stack: Inter, Roboto, 'Helvetica Neue', 'Arial Nova', 'Nimbus Sans', Arial, sans-serif, var(--emoji-system-stack);
--text-primary: CanvasText;
--text-faded: GrayText;
--horizontal-rule: #88929d;
@ -167,7 +170,7 @@
--text-link: #0069c2;
--text-invert: #fff;
--background-primary: #fff;
--background-secondary: #f9f9fb;
--background-secondary: #ebebeb;
--background-tertiary: #fff;
--background-toc-active: #ebeaea;
--background-mark-yellow: #c7b70066;
@ -344,6 +347,7 @@
}
main.layout>.rightside { grid-area: rightside; }
main.layout>footer {
font-family: var(--grotesque-system-stack);
grid-area: footer;
border-top: 2px inset;
margin-block-start: 1rem;
@ -370,6 +374,8 @@
main.layout>div.header>h1 {
margin: 1rem;
font-family: var(--grotesque-system-stack);
font-size: xx-large;
}
main.layout>div.header>div.page-header {
@ -416,6 +422,7 @@
main.layout>div.header h2.page-title {
margin: 1rem 0px;
font-family: var(--grotesque-system-stack);
}
nav.breadcrumbs {
@ -649,6 +656,10 @@
margin-inline-end: 1rem;
}
table.headers {
margin-left: -3vw;
}
table.headers tr>th {
text-align: right;
color: var(--text-faded);
@ -657,29 +668,44 @@
table.headers th[scope="row"] {
padding-right: .5rem;
vertical-align: top;
font-family: var(--grotesque-system-stack);
}
table.headers tr>td {
overflow-wrap: break-word;
hyphens: auto;
word-wrap: anywhere;
word-break: break-all;
width: 50ch;
}
div.post-body {
margin: 1rem;
margin: 1rem 0px;
}
div.post-body>pre {
max-width: 69vw;
overflow-wrap: break-word;
white-space: pre-line;
margin-left: min(5rem, 14vw);
hyphens: auto;
background-color: var(--background-secondary);
outline: 1rem solid var(--background-secondary);
margin: 2rem 0;
line-height: 1.333;
}
div.post-body:not(:last-child) {
padding-bottom: .5rem;
border-bottom: 1px solid var(--horizontal-rule);
}
td.message-id,
span.message-id{
color: var(--text-faded);
}
.message-id>a {
overflow-wrap: break-word;
hyphens: auto;
}
td.message-id:before,
span.message-id:before{
content: '<';
@ -705,6 +731,13 @@
span.faded:is(:focus, :hover, :focus-visible, :focus-within) {
color: revert;
}
details.reply-details button {
padding: 0.3rem;
font-size: medium;
min-width: 0;
margin-block-start: 0.2rem;
display: inline-block;
}
ul.lists {
padding: 1rem 2rem;

View File

@ -94,6 +94,7 @@
<div class="entry" role="listitem" aria-labelledby="post_link_{{ loop.index }}">
<span class="subject"><a id="post_link_{{ loop.index }}" href="{{ root_url_prefix }}{{ list_post_path(list.id, post.message_id) }}">{{ post.subject }}</a>&nbsp;<span class="metadata replies" title="reply count">{{ post.replies }} repl{{ post.replies|pluralize("y","ies") }}</span></span>
<span class="metadata"><span aria-hidden="true">👤&nbsp;</span><span class="from" title="post author">{{ post.address }}</span><span aria-hidden="true"> 📆&nbsp;</span><span class="date" title="post date">{{ post.datetime }}</span></span>
{% if post.replies > 0 %}<span class="metadata"><span aria-hidden="true">&#x1F493;&nbsp;</span><span class="last-active" title="latest thread activity">{{ post.last_active }}</span></span>{% endif %}
<span class="metadata"><span aria-hidden="true">🪪 </span><span class="message-id" title="e-mail Message-ID">{{ post.message_id }}</span></span>
</div>
{% endfor %}

View File

@ -38,5 +38,29 @@
<div class="post-body">
<pre title="E-mail text content">{{ body }}</pre>
</div>
{% for (depth, post, body, date) in thread %}
<table class="headers" title="E-mail headers">
<caption class="screen-reader-only">E-mail headers</caption>
<tr>
<th scope="row">From:</th>
<td>{{ post.address }}</td>
</tr>
<tr>
<th scope="row">Date:</th>
<td class="faded">{{ date }}</td>
</tr>
<tr>
<th scope="row">Message-ID:</th>
<td class="faded message-id"><a href="{{ root_url_prefix }}{{ list_post_path(list.id, post.message_id) }}">{{ strip_carets(post.message_id) }}</a></td>
</tr>
<tr>
<td colspan="2"><details class="reply-details"><summary>more …</summary><a href="{{ root_url_prefix }}{{ post_raw_path(list.id, post.message_id) }}">View raw</a> <a href="{{ root_url_prefix }}{{ 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 title="E-mail text content">{{ body }}</pre>
</div>
{% endfor %}
</div>
{% include "footer.html" %}

View File

@ -90,6 +90,14 @@ impl From<&DbVal<mailpot::models::MailingList>> for ListPath {
#[typed_path("/list/:id/posts/:msgid/")]
pub struct ListPostPath(pub ListPathIdentifier, pub String);
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
#[typed_path("/list/:id/posts/:msgid/raw/")]
pub struct ListPostRawPath(pub ListPathIdentifier, pub String);
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
#[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/edit/")]
pub struct ListEditPath(pub ListPathIdentifier);
@ -159,31 +167,39 @@ list_id_impl!(list_edit_path, ListEditPath);
list_id_impl!(list_subscribers_path, ListEditSubscribersPath);
list_id_impl!(list_candidates_path, ListEditCandidatesPath);
pub fn list_post_path(id: Value, msg_id: Value) -> std::result::Result<Value, Error> {
let Some(msg_id) = msg_id.as_str().map(|s| if s.starts_with('<') && s.ends_with('>') { s.to_string() } else {
format!("<{s}>")
}) else {
return Err(Error::new(
minijinja::ErrorKind::UnknownMethod,
"Second argument of list_post_path must be a string."
));
};
macro_rules! list_post_impl {
($ident:ident, $ty:tt) => {
pub fn $ident(id: Value, msg_id: Value) -> std::result::Result<Value, Error> {
let Some(msg_id) = msg_id.as_str().map(|s| if s.starts_with('<') && s.ends_with('>') { s.to_string() } else {
format!("<{s}>")
}) else {
return Err(Error::new(
minijinja::ErrorKind::UnknownMethod,
"Second argument of list_post_path must be a string."
));
};
if let Some(id) = id.as_str() {
return Ok(Value::from_safe_string(
ListPostPath(ListPathIdentifier::Id(id.to_string()), msg_id)
.to_crumb()
.to_string(),
));
}
let pk = id.try_into()?;
Ok(Value::from_safe_string(
ListPostPath(ListPathIdentifier::Pk(pk), msg_id)
.to_crumb()
.to_string(),
))
if let Some(id) = id.as_str() {
return Ok(Value::from_safe_string(
$ty(ListPathIdentifier::Id(id.to_string()), msg_id)
.to_crumb()
.to_string(),
));
}
let pk = id.try_into()?;
Ok(Value::from_safe_string(
$ty(ListPathIdentifier::Pk(pk), msg_id)
.to_crumb()
.to_string(),
))
}
};
}
list_post_impl!(list_post_path, ListPostPath);
list_post_impl!(post_raw_path, ListPostRawPath);
list_post_impl!(post_eml_path, ListPostEmlPath);
pub mod tsr {
use std::{borrow::Cow, convert::Infallible};

View File

@ -212,3 +212,202 @@ where
.map(|s| Value::from_safe_string(s.to_string()))
.serialize(ser)
}
pub struct ThreadEntry {
pub hash: melib::EnvelopeHash,
pub depth: usize,
pub thread_node: melib::ThreadNodeHash,
pub thread: melib::ThreadHash,
pub from: String,
pub message_id: String,
pub timestamp: u64,
pub datetime: String,
}
pub fn thread_db(
db: &mailpot::Connection,
list: i64,
root: &str,
) -> Vec<(i64, DbVal<mailpot::models::Post>, String, String)> {
let mut stmt = db
.connection
.prepare(
"WITH RECURSIVE cte_replies AS MATERIALIZED
(
SELECT
pk,
message_id,
REPLACE(
TRIM(
SUBSTR(
CAST(message AS TEXT),
INSTR(
CAST(message AS TEXT),
'In-Reply-To: '
)
+
LENGTH('in-reply-to: '),
INSTR(
SUBSTR(
CAST(message AS TEXT),
INSTR(
CAST(message AS TEXT),
'In-Reply-To: ')
+
LENGTH('in-reply-to: ')
),
'>'
)
)
),
' ',
''
) AS in_reply_to,
INSTR(
CAST(message AS TEXT),
'In-Reply-To: '
) AS offset
FROM post
WHERE
offset > 0
UNION
SELECT
pk,
message_id,
NULL AS in_reply_to,
INSTR(
CAST(message AS TEXT),
'In-Reply-To: '
) AS offset
FROM post
WHERE
offset = 0
),
cte_thread(parent, root, depth) AS (
SELECT DISTINCT
message_id AS parent,
message_id AS root,
0 AS depth
FROM cte_replies
WHERE
in_reply_to IS NULL
UNION ALL
SELECT
t.message_id AS parent,
cte_thread.root AS root,
(cte_thread.depth + 1) AS depth
FROM cte_replies
AS t
JOIN
cte_thread
ON cte_thread.parent = t.in_reply_to
WHERE t.in_reply_to IS NOT NULL
)
SELECT * FROM cte_thread WHERE root = ? ORDER BY root, depth;",
)
.unwrap();
let iter = stmt
.query_map(rusqlite::params![root], |row| {
let parent: String = row.get("parent")?;
let root: String = row.get("root")?;
let depth: i64 = row.get("depth")?;
Ok((parent, root, depth))
})
.unwrap();
let mut ret = vec![];
for post in iter {
let post = post.unwrap();
ret.push(post);
}
let posts = db.list_posts(list, None).unwrap();
ret.into_iter()
.filter_map(|(m, _, depth)| {
posts
.iter()
.find(|p| m.as_str().strip_carets() == p.message_id.as_str().strip_carets())
.map(|p| {
let envelope = melib::Envelope::from_bytes(p.message.as_slice(), None).unwrap();
let body = envelope.body_bytes(p.message.as_slice());
let body_text = body.text();
let date = envelope.date_as_str().to_string();
(depth, p.clone(), body_text, date)
})
})
.skip(1)
.collect()
}
pub fn thread(
envelopes: &Arc<std::sync::RwLock<HashMap<melib::EnvelopeHash, melib::Envelope>>>,
threads: &melib::Threads,
root_env_hash: melib::EnvelopeHash,
) -> Vec<ThreadEntry> {
let env_lock = envelopes.read().unwrap();
let thread = threads.envelope_to_thread[&root_env_hash];
let mut ret = vec![];
for (depth, t) in threads.thread_group_iter(thread) {
let hash = threads.thread_nodes[&t].message.unwrap();
ret.push(ThreadEntry {
hash,
depth,
thread_node: t,
thread,
message_id: env_lock[&hash].message_id().to_string(),
from: env_lock[&hash].field_from_to_string(),
datetime: env_lock[&hash].date_as_str().to_string(),
timestamp: env_lock[&hash].timestamp,
});
}
ret
}
pub fn thread_roots(
envelopes: &Arc<std::sync::RwLock<HashMap<melib::EnvelopeHash, melib::Envelope>>>,
threads: &mut melib::Threads,
) -> Vec<(ThreadEntry, usize, u64)> {
let items = threads.roots();
let env_lock = envelopes.read().unwrap();
let mut ret = vec![];
'items_for_loop: for thread in items {
let mut iter_ptr = threads.thread_ref(thread).root();
let thread_node = &threads.thread_nodes()[&iter_ptr];
let root_env_hash = if let Some(h) = thread_node.message().or_else(|| {
if thread_node.children().is_empty() {
return None;
}
iter_ptr = thread_node.children()[0];
while threads.thread_nodes()[&iter_ptr].message().is_none() {
if threads.thread_nodes()[&iter_ptr].children().is_empty() {
return None;
}
iter_ptr = threads.thread_nodes()[&iter_ptr].children()[0];
}
threads.thread_nodes()[&iter_ptr].message()
}) {
h
} else {
continue 'items_for_loop;
};
if !env_lock.contains_key(&root_env_hash) {
panic!("key = {}", root_env_hash);
}
let envelope: &melib::Envelope = &env_lock[&root_env_hash];
let tref = threads.thread_ref(thread);
ret.push((
ThreadEntry {
hash: root_env_hash,
depth: 0,
thread_node: iter_ptr,
thread,
message_id: envelope.message_id().to_string(),
from: envelope.field_from_to_string(),
datetime: envelope.date_as_str().to_string(),
timestamp: envelope.timestamp,
},
tref.len,
tref.date,
));
}
ret.sort_by_key(|(_, _, key)| std::cmp::Reverse(*key));
ret
}