Commit
Author: Kevin Schoon [me@kevinschoon.com]
Hash: 7a7c559893089b3c10541ee9d6886884e6045f00
Timestamp: Wed, 03 Jan 2024 12:14:52 +0000 (1 year ago)

+279 -95 +/-12 browse
flesh out more mail integration
1diff --git a/Cargo.lock b/Cargo.lock
2index 68b08a3..4aeb8e0 100644
3--- a/Cargo.lock
4+++ b/Cargo.lock
5 @@ -138,9 +138,9 @@ dependencies = [
6
7 [[package]]
8 name = "anyhow"
9- version = "1.0.75"
10+ version = "1.0.78"
11 source = "registry+https://github.com/rust-lang/crates.io-index"
12- checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6"
13+ checksum = "ca87830a3e3fb156dc96cfbd31cb620265dd053be734723f22b760d6cc3c3051"
14
15 [[package]]
16 name = "arrayvec"
17 @@ -585,6 +585,7 @@ dependencies = [
18 name = "ayllu-mail"
19 version = "0.2.1"
20 dependencies = [
21+ "anyhow",
22 "ayllu_api",
23 "ayllu_config",
24 "ayllu_rpc",
25 @@ -2895,7 +2896,7 @@ dependencies = [
26 [[package]]
27 name = "mailpot"
28 version = "0.1.1"
29- source = "git+https://ayllu-forge.org/forks/mailpot?branch=ayllu-dev#27dd84e1ff491418832cc64b2758e36197a6c6ba"
30+ source = "git+https://ayllu-forge.org/forks/mailpot?branch=ayllu-dev#30a34815d96cc642963ea9aaa5b9c4f36db459d2"
31 dependencies = [
32 "anyhow",
33 "chrono",
34 diff --git a/ayllu-mail/Cargo.toml b/ayllu-mail/Cargo.toml
35index 5fb51f0..afb1c6b 100644
36--- a/ayllu-mail/Cargo.toml
37+++ b/ayllu-mail/Cargo.toml
38 @@ -18,3 +18,4 @@ capnp = "0.18.1"
39 melib = "0.8.2"
40 mailpot = { git = "https://ayllu-forge.org/forks/mailpot", branch = "ayllu-dev"}
41 clap_complete = "4.4.5"
42+ anyhow = "1.0.78"
43 diff --git a/ayllu-mail/src/declarative.rs b/ayllu-mail/src/declarative.rs
44index 0708e12..ad1dfd7 100644
45--- a/ayllu-mail/src/declarative.rs
46+++ b/ayllu-mail/src/declarative.rs
47 @@ -1,7 +1,7 @@
48 use std::collections::HashMap;
49
50 use mailpot::{
51- models::{MailingList, PostPolicy, SubscriptionPolicy},
52+ models::{changesets::MailingListChangeset, MailingList, PostPolicy, SubscriptionPolicy},
53 Connection, Result as MpResult,
54 };
55 use tracing::log;
56 @@ -23,7 +23,6 @@ pub fn initialize(cfg: &Config) -> MpResult<()> {
57
58 for list in cfg.mail.lists.iter() {
59 log::info!("configuring mailing list: {} [{}]", list.id, list.address);
60- // TODO: confused on distinciton between id and name
61 if !lists_by_name.contains_key(&list.id) {
62 log::info!("creating mailing list: {}", list.address);
63 let db_list = db.create_list(MailingList {
64 @@ -36,6 +35,18 @@ pub fn initialize(cfg: &Config) -> MpResult<()> {
65 archive_url: None,
66 })?;
67 lists_by_name.insert(db_list.id.clone(), db_list.pk);
68+ } else {
69+ let pk = *lists_by_name.get(&list.id).unwrap();
70+ // ensure the configuration is consistent
71+ db.update_list(MailingListChangeset {
72+ pk,
73+ // TODO: topics cannot be updated?
74+ description: list
75+ .description
76+ .as_ref()
77+ .map(|description| Some(description.clone())),
78+ ..Default::default()
79+ })?;
80 }
81 managed_lists.push(list.id.clone());
82 }
83 @@ -45,7 +56,7 @@ pub fn initialize(cfg: &Config) -> MpResult<()> {
84 for list in cfg.mail.lists.iter() {
85 let list_pk = *lists_by_name.get(&list.id).unwrap();
86 let post_policy = PostPolicy {
87- pk: 0, // TODO ?
88+ pk: 0,
89 list: list_pk,
90 announce_only: matches!(list.post_policy, config::PostPolicy::AnnounceOnly),
91 subscription_only: matches!(list.post_policy, config::PostPolicy::SubscriptionOnly),
92 @@ -93,11 +104,20 @@ pub fn initialize(cfg: &Config) -> MpResult<()> {
93 .collect();
94
95 if extras.len() > 0 {
96- // TODO: there is no way to delete lists currently
97 log::info!("database contains the following superfluous lists:");
98 extras.iter().for_each(|extra| {
99 log::info!("List: {}", extra);
100 });
101+ for extra in extras.iter() {
102+ log::info!("disabling mailing list: {}", extra);
103+ let pk = *lists_by_name.get(&extra.to_string()).unwrap();
104+ db.update_list(MailingListChangeset {
105+ pk,
106+ enabled: Some(false),
107+ hidden: Some(true),
108+ ..Default::default()
109+ })?;
110+ }
111 }
112
113 Ok(())
114 diff --git a/ayllu-mail/src/server.rs b/ayllu-mail/src/server.rs
115index f21b111..9822a1c 100644
116--- a/ayllu-mail/src/server.rs
117+++ b/ayllu-mail/src/server.rs
118 @@ -2,15 +2,20 @@ use std::collections::HashMap;
119 use std::error::Error as StdError;
120 use std::sync::{Arc, RwLock};
121
122+ use anyhow::{format_err, Result};
123 use capnp::{capability::Promise, Error as CapnpError};
124 use capnp_rpc::pry;
125- use mailpot::{models::Post, Connection};
126+ use mailpot::{
127+ models::{DbVal, Post},
128+ Connection, Error as MailpotError,
129+ };
130 use melib::{thread::Threads, Envelope, EnvelopeHash};
131 use tracing::log;
132
133 use crate::config::Config;
134 use ayllu_api::mail_capnp::server::{
135- Client, ListThreadsParams, ListThreadsResults, ReadThreadParams, ReadThreadResults, Server,
136+ Client, ListThreadsParams, ListThreadsResults, ReadPostParams, ReadPostResults,
137+ ReadThreadParams, ReadThreadResults, Server,
138 };
139 use ayllu_rpc::Server as RpcHelper;
140
141 @@ -22,67 +27,139 @@ impl Server for ServerImpl {
142 fn list_threads(
143 &mut self,
144 params: ListThreadsParams,
145- mut results: ListThreadsResults,
146+ results: ListThreadsResults,
147 ) -> Promise<(), CapnpError> {
148- let mailing_list_name = pry!(pry!(pry!(params.get()).get_name()).to_string());
149- log::info!("looking up threads: {}", mailing_list_name);
150- for list in self.db.lists().unwrap() {
151- if list.address == mailing_list_name {
152- let posts = self.db.list_posts(list.pk, None).unwrap();
153- log::info!("processing {} posts", posts.len());
154- let mut posts_by_hash: HashMap<EnvelopeHash, Post> = HashMap::new();
155- let mut envelopes: HashMap<EnvelopeHash, Envelope> = HashMap::new();
156- for post in &posts {
157- let envelope = Envelope::from_bytes(&post.message, None).unwrap();
158- let envelope_hash = envelope.hash;
159- posts_by_hash.insert(envelope_hash, post.0.clone());
160- envelopes.insert(envelope_hash, envelope);
161- }
162- let mut threads = Threads::new(posts.len());
163- let envelopes = Arc::new(RwLock::new(envelopes));
164- threads.amend(&envelopes);
165- let envelopes = envelopes.read().unwrap();
166- let mut root_threads: Vec<(Post, Envelope, usize)> = Vec::new();
167- for node in threads.thread_nodes.into_iter() {
168- if !node.1.has_parent() {
169- let n_replies = node.1.children.len();
170- let envelope_hash = node.1.message().unwrap();
171- let envelope = envelopes.get(&envelope_hash).unwrap();
172- let post = posts_by_hash.get(&envelope_hash).unwrap();
173- root_threads.push((post.clone(), envelope.clone(), n_replies));
174- }
175- }
176- let mut threads = results.get().init_threads(root_threads.len() as u32);
177- for (i, thread) in root_threads.into_iter().enumerate() {
178- let mut result_thread = threads.reborrow().get(i as u32);
179- result_thread.set_n_replies(thread.2 as i64);
180- let mut message = result_thread.init_first();
181- message.set_id(thread.0.pk);
182- message.set_address(thread.0.address.as_str().into());
183- message.set_from(thread.1.from()[0].get_email().as_str().into());
184- message.set_subject(thread.1.subject.unwrap_or(String::new()).as_str().into());
185- message.set_timestamp(thread.1.timestamp as i64);
186+ let list_id = pry!(pry!(params.get()).get_id()).to_string().unwrap();
187+ let list_threads = |mut results: ListThreadsResults| {
188+ let list = self
189+ .db
190+ .list_by_id(&list_id)?
191+ .ok_or(format_err!("no mailing list with id: {}", &list_id))?;
192+ let posts = self.db.list_posts(list.pk, None)?;
193+ // TODO: add a new database method to only return posts without
194+ // reply headers to save cpu cycles
195+ let posts: Vec<&DbVal<Post>> = posts
196+ .iter()
197+ .filter(|post| {
198+ let envelope = Envelope::from_bytes(&post.0.message, None).unwrap();
199+ envelope.in_reply_to.is_none()
200+ })
201+ .collect();
202+ let mut threads = results.get().init_threads(posts.len() as u32);
203+ for (i, post) in posts.iter().enumerate() {
204+ let envelope = Envelope::from_bytes(&post.0.message, None).unwrap();
205+ let message_text = envelope.body_bytes(&post.0.message).text();
206+ let replies = self.db.list_thread(list.pk, &post.message_id)?;
207+ let mut thread = threads.reborrow().get(i as u32);
208+ thread.reborrow().set_n_replies(replies.len() as i64);
209+ let mut first = thread.get_first().unwrap();
210+ first.set_from(envelope.from[0].get_email().as_str().into());
211+ first.set_id(post.pk);
212+ first.set_address(post.0.address.as_str().into());
213+ first.set_message_id(post.0.message_id.as_str().into());
214+ first.set_timestamp(post.0.timestamp as i64);
215+ if let Some(subject) = envelope.subject {
216+ first.set_subject(subject.as_str().into());
217 }
218+ let message = String::from_utf8(post.0.message.clone()).unwrap();
219+ first.set_body(message.as_str().into());
220+ first.set_text(message_text.as_str().into());
221 }
222+ Ok::<(), MailpotError>(())
223+ };
224+ match list_threads(results) {
225+ Ok(_) => Promise::ok(()),
226+ Err(e) => Promise::err(CapnpError::failed(e.to_string())),
227 }
228- Promise::ok(())
229 }
230
231 fn read_thread(
232 &mut self,
233 params: ReadThreadParams,
234- mut results: ReadThreadResults,
235+ results: ReadThreadResults,
236+ ) -> Promise<(), CapnpError> {
237+ let params = pry!(params.get());
238+ let list_id = pry!(pry!(params.get_id()).to_string());
239+ let message_id = pry!(pry!(params.get_message_id()).to_string());
240+ let read_thread = |mut results: ReadThreadResults| {
241+ let list = self
242+ .db
243+ .list_by_id(&list_id)?
244+ .ok_or(format_err!("no mailing list with id: {}", list_id))?;
245+ let first_post = self
246+ .db
247+ .list_post_by_message_id(list.pk, &message_id)?
248+ .ok_or(format_err!("cannot find post: {}", message_id))?;
249+ let replies = self.db.list_thread(list.pk, &message_id)?;
250+ let mut threads = results.get().init_thread(replies.len() as u32 + 1);
251+ let mut first = threads.reborrow().get(0);
252+ first.set_id(first_post.0.pk);
253+ first.set_address(first_post.0.address.as_str().into());
254+ first.set_message_id(first_post.0.message_id.as_str().into());
255+ let envelope = Envelope::from_bytes(&first_post.0.message, None).unwrap();
256+ let message_text = envelope.body_bytes(&first_post.0.message).text();
257+ first.set_text(message_text.as_str().into());
258+ let message_body = String::from_utf8(first_post.0.message).unwrap();
259+ first.set_body(message_body.as_str().into());
260+ first.set_timestamp(first_post.0.timestamp as i64);
261+ for (i, reply) in replies.iter().enumerate() {
262+ let mut next = threads.reborrow().get(i as u32 + 1);
263+ let envelope = Envelope::from_bytes(&reply.1.message, None).unwrap();
264+ next.set_id(reply.1.pk);
265+ next.set_address(reply.1.address.as_str().into());
266+ next.set_message_id(reply.1 .0.message_id.as_str().into());
267+ let message_body = String::from_utf8(reply.1.message.to_vec()).unwrap();
268+ next.set_body(message_body.as_str().into());
269+ let message_text = envelope.body_bytes(&reply.1.message).text();
270+ next.set_text(message_text.as_str().into());
271+ }
272+ Ok::<(), MailpotError>(())
273+ };
274+ match read_thread(results) {
275+ Ok(_) => Promise::ok(()),
276+ Err(e) => Promise::err(CapnpError::failed(e.to_string())),
277+ }
278+ }
279+
280+ fn read_post(
281+ &mut self,
282+ params: ReadPostParams,
283+ results: ReadPostResults,
284 ) -> Promise<(), CapnpError> {
285 let params = pry!(params.get());
286- let (thread_id, offset, limit) = (params.get_id(), params.get_offset(), params.get_limit());
287- ::capnp::capability::Promise::err(::capnp::Error::unimplemented(
288- "method server::Server::read_thread not implemented".to_string(),
289- ))
290+ let list_id = pry!(pry!(params.get_id()).to_string());
291+ let message_id = pry!(pry!(params.get_message_id()).to_string());
292+ let read_post = |mut results: ReadPostResults| {
293+ let list = self
294+ .db
295+ .list_by_id(&list_id)?
296+ .ok_or(format_err!("no mailing list with id: {}", list_id))?;
297+ let post = self
298+ .db
299+ .list_post_by_message_id(list.pk, &message_id)?
300+ .ok_or(format_err!("failed to find post with id: {}", message_id))?;
301+ let mut message = results.get();
302+ let envelope = Envelope::from_bytes(&post.message, None).unwrap();
303+ message.set_id(post.pk);
304+ message.set_address(post.address.as_str().into());
305+ if let Some(from) = post.envelope_from.as_ref() {
306+ message.set_from(from.as_str().into());
307+ }
308+ let message_body = String::from_utf8(post.message.to_vec()).unwrap();
309+ message.set_body(message_body.as_str().into());
310+ let message_text = envelope.body_bytes(&post.message).text();
311+ message.set_text(message_text.as_str().into());
312+ Ok::<(), MailpotError>(())
313+ };
314+ match read_post(results) {
315+ Ok(_) => Promise::ok(()),
316+ Err(e) => Promise::err(CapnpError::failed(e.to_string())),
317+ }
318 }
319 }
320
321 pub async fn serve(cfg: &Config) -> Result<(), Box<dyn StdError>> {
322- let db = Connection::open_or_create_db(cfg.mailpot_config())?;
323+ let db = Connection::open_or_create_db(cfg.mailpot_config())?.trusted();
324 let server = ServerImpl { db };
325 let runtime = RpcHelper::<Client, ServerImpl>::new(&cfg.mail.socket_path, server);
326 runtime.serve().await?;
327 diff --git a/crates/api/v1/mail.capnp b/crates/api/v1/mail.capnp
328index 797f45d..eb951e7 100644
329--- a/crates/api/v1/mail.capnp
330+++ b/crates/api/v1/mail.capnp
331 @@ -1,14 +1,23 @@
332 @0xe282ac1f72de3195;
333
334+ struct ThreadedMessage {
335+ depth @0 :Int64;
336+ message @1 :Message;
337+ }
338+
339 # a single email
340 struct Message {
341 # primary key of the message in the mailpot db
342 id @0 :Int64;
343- timestamp @1 :Int64;
344- from @2 :Text;
345- address @3 :Text;
346- subject @4 :Text;
347- body @5 :Text;
348+ messageId @1 :Text;
349+ timestamp @2 :Int64;
350+ from @3 :Text;
351+ address @4 :Text;
352+ subject @5 :Text;
353+ # entire email including headers
354+ body @6 :Text;
355+ # text of just the email message
356+ text @7 :Text;
357 }
358
359 struct Thread {
360 @@ -20,7 +29,9 @@ struct Thread {
361
362 interface Server {
363 # list all of the threads associated with a mailing list
364- listThreads @0 (name: Text, offset: Int64, limit: Int64) -> (threads: List(Thread));
365- # list all of the responses associated with a post
366- readThread @1 (id: Int64, offset: Int64, limit: Int64) -> (thread: List(Message));
367+ listThreads @0 (id: Text, offset: Int64, limit: Int64) -> (threads: List(Thread));
368+ # list all of the responses associated with a post starting from messageId
369+ readThread @1 (id: Text, messageId: Text, offset: Int64, limit: Int64) -> (thread: List(Message));
370+ # read a single post
371+ readPost @2 (id: Text, messageId: Text) -> Message;
372 }
373 diff --git a/scripts/watch.sh b/scripts/watch.sh
374index 6f3cf4b..24d9916 100755
375--- a/scripts/watch.sh
376+++ b/scripts/watch.sh
377 @@ -19,6 +19,8 @@ if [ "${COMPONENT}" = "." ] ; then
378 -s 'scripts/compile_stylesheets.sh && cargo run -- serve'
379 elif [ "${COMPONENT}" = "ayllu-build" ] ; then
380 cargo watch -s 'cargo run -- serve'
381+ elif [ "${COMPONENT}" = "ayllu-mail" ] ; then
382+ cargo watch -s 'cargo run -- serve'
383 else
384 cargo watch -x run
385 fi
386 diff --git a/src/web2/routes/mail.rs b/src/web2/routes/mail.rs
387index e795a8f..d835715 100644
388--- a/src/web2/routes/mail.rs
389+++ b/src/web2/routes/mail.rs
390 @@ -4,7 +4,6 @@ use axum::{
391 response::Html,
392 };
393 use serde::{Deserialize, Serialize};
394- use time::OffsetDateTime;
395
396 use crate::config::Config;
397 use crate::web2::error::Error;
398 @@ -16,25 +15,28 @@ use ayllu_api::mail_capnp::server::Client as MailClient;
399
400 #[derive(Deserialize)]
401 pub struct Params {
402- pub address: String,
403- pub thread_id: Option<i64>,
404+ pub list_id: String,
405+ pub message_id: Option<String>,
406 }
407
408 #[derive(Debug, Serialize, Default)]
409 struct Thread {
410 pub id: i64,
411+ pub message_id: String,
412 pub from: String,
413 pub subject: String,
414 pub n_replies: i64,
415- pub timestamp: String,
416+ pub timestamp: i64,
417 }
418
419 #[derive(Debug, Serialize, Default)]
420 struct Message {
421 pub id: String,
422- pub created_at: String,
423+ pub message_id: String,
424+ pub created_at: i64,
425 pub from_address: String,
426 pub body: String,
427+ pub text: String,
428 }
429
430 pub async fn lists(
431 @@ -50,6 +52,7 @@ pub async fn lists(
432 let body = templates.render("lists.html", &ctx)?;
433 Ok(Html(body))
434 }
435+
436 #[debug_handler]
437 pub async fn threads(
438 Path(params): Path<Params>,
439 @@ -59,42 +62,42 @@ pub async fn threads(
440 Extension((templates, mut ctx)): Extension<Template>,
441 ) -> Result<Html<String>, Error> {
442 let lists = cfg.mail.unwrap().lists;
443- let list = match lists.iter().find(|list| list.address == params.address) {
444+ let list = match lists.iter().find(|list| list.id == params.list_id) {
445 Some(list) => Ok(list),
446 None => Err(Error::Message(format!(
447 "no list associated with: {}",
448- params.address
449+ params.list_id
450 ))),
451 }?;
452 ctx.insert("title", &format!("list {}", list.address));
453 ctx.insert("nav_elements", &navigation::global("dicsuss", true));
454 ctx.insert("list", list);
455- ctx.insert("address", &params.address);
456 ctx.insert("discnav", &util::navigation::discnav("mail"));
457
458 let mail_client = initiator.client(InitiatorKind::Mail).unwrap();
459- let threads = mail_client
460+ let mut threads = mail_client
461 .invoke(move |c: MailClient| async move {
462 let mut threads: Vec<Thread> = Vec::new();
463 let mut req = c.list_threads_request();
464- req.get().set_name(params.address.as_str().into());
465+ req.get().set_id(params.list_id.as_str().into());
466 let result = req.send().promise.await?;
467 let rpc_threads = result.get()?.get_threads()?;
468 for thread in rpc_threads.iter() {
469 let message = thread.get_first()?;
470- let timestamp =
471- OffsetDateTime::from_unix_timestamp(message.get_timestamp()).unwrap();
472+ let message_id = message.get_message_id()?.to_string().unwrap();
473 threads.push(Thread {
474 id: message.get_id(),
475+ message_id,
476 from: message.get_from()?.to_string().unwrap(),
477 subject: message.get_subject()?.to_string().unwrap(),
478 n_replies: thread.get_n_replies(),
479- timestamp: timestamp.to_string(),
480+ timestamp: thread.get_first().unwrap().get_timestamp(),
481 })
482 }
483 Ok(threads)
484 })
485 .await?;
486+ threads.sort_by(|first, second| second.timestamp.cmp(&first.timestamp));
487 ctx.insert("threads", &threads);
488 let body = templates.render("threads.html", &ctx)?;
489 Ok(Html(body))
490 @@ -107,11 +110,11 @@ pub async fn thread(
491 Extension((templates, mut ctx)): Extension<Template>,
492 ) -> Result<Html<String>, Error> {
493 let lists = cfg.mail.unwrap().lists;
494- let list = match lists.iter().find(|list| list.address == params.address) {
495+ let list = match lists.iter().find(|list| list.id == params.list_id) {
496 Some(list) => Ok(list),
497 None => Err(Error::Message(format!(
498 "no list associated with: {}",
499- params.address
500+ params.list_id
501 ))),
502 }?;
503 let mail_client = initiator.client(InitiatorKind::Mail).unwrap();
504 @@ -119,14 +122,18 @@ pub async fn thread(
505 .invoke(move |c: MailClient| async move {
506 let mut messages: Vec<Message> = Vec::new();
507 let mut req = c.read_thread_request();
508- req.get().set_id(params.thread_id.unwrap());
509+ req.get().set_id(params.list_id.as_str().into());
510+ req.get()
511+ .set_message_id(params.message_id.unwrap().as_str().into());
512 let result = req.send().promise.await?;
513 for message in result.get()?.get_thread()? {
514 messages.push(Message {
515 id: message.get_id().to_string(),
516- created_at: message.get_timestamp().to_string(), // TODO no epoch!
517+ message_id: message.get_message_id()?.to_string().unwrap(),
518+ created_at: message.get_timestamp(),
519 from_address: message.get_address()?.to_string().unwrap(),
520 body: message.get_body()?.to_string().unwrap(),
521+ text: message.get_text()?.to_string().unwrap(),
522 })
523 }
524 Ok(messages)
525 @@ -136,7 +143,51 @@ pub async fn thread(
526 ctx.insert("nav_elements", &navigation::global("dicsuss", true));
527 ctx.insert("discnav", &util::navigation::discnav("mail"));
528 ctx.insert("list", list);
529- ctx.insert("message", &messages);
530+ ctx.insert("list_id", &list.id);
531+ ctx.insert("messages", &messages);
532 let body = templates.render("thread.html", &ctx)?;
533 Ok(Html(body))
534 }
535+
536+ pub async fn post(
537+ Path(params): Path<Params>,
538+ Extension(initiator): Extension<Initiator>,
539+ Extension(cfg): Extension<Config>,
540+ Extension((templates, mut ctx)): Extension<Template>,
541+ ) -> Result<Html<String>, Error> {
542+ let lists = cfg.mail.unwrap().lists;
543+ let list = match lists.iter().find(|list| list.id == params.list_id) {
544+ Some(list) => Ok(list),
545+ None => Err(Error::Message(format!(
546+ "no list associated with: {}",
547+ params.list_id
548+ ))),
549+ }?;
550+ let mail_client = initiator.client(InitiatorKind::Mail).unwrap();
551+ let message = mail_client
552+ .invoke(move |c: MailClient| async move {
553+ let mut req = c.read_post_request();
554+ req.get().set_id(params.list_id.as_str().into());
555+ req.get()
556+ .set_message_id(params.message_id.unwrap().as_str().into());
557+ let result = req.send().promise.await?;
558+ let message = result.get()?;
559+ Ok(Message {
560+ id: message.get_id().to_string(),
561+ message_id: message.get_message_id()?.to_string().unwrap(),
562+ created_at: message.get_timestamp(),
563+ body: message.get_body()?.to_string().unwrap(),
564+ text: message.get_text()?.to_string().unwrap(),
565+ from_address: message.get_address()?.to_string().unwrap(),
566+ })
567+ })
568+ .await?;
569+ ctx.insert("title", &format!("list {}", list.address));
570+ ctx.insert("nav_elements", &navigation::global("dicsuss", true));
571+ ctx.insert("discnav", &util::navigation::discnav("mail"));
572+ ctx.insert("list", list);
573+ ctx.insert("list_id", &list.id);
574+ ctx.insert("message", &message);
575+ let body = templates.render("post.html", &ctx)?;
576+ Ok(Html(body))
577+ }
578 diff --git a/src/web2/server.rs b/src/web2/server.rs
579index bb597ad..653173b 100644
580--- a/src/web2/server.rs
581+++ b/src/web2/server.rs
582 @@ -186,8 +186,9 @@ pub async fn serve(cfg: &Config) -> Result<(), Box<dyn Error>> {
583 "/mail",
584 Router::new()
585 .route("/", routing::get(mail::lists))
586- .route("/:address", routing::get(mail::threads))
587- .route("/:address/:thread_id", routing::get(mail::thread))
588+ .route("/:list_id", routing::get(mail::threads))
589+ .route("/:list_id/:message_id", routing::get(mail::thread))
590+ .route("/post/:list_id/:message_id", routing::get(mail::post))
591 .layer(from_fn_with_state(
592 Arc::new((cfg.clone(), templates.clone(), mail_required_plugins)),
593 rpc_initiator::required,
594 diff --git a/themes/default/templates/lists.html b/themes/default/templates/lists.html
595index 13631e6..bdb32ba 100644
596--- a/themes/default/templates/lists.html
597+++ b/themes/default/templates/lists.html
598 @@ -8,14 +8,18 @@
599 </header>
600 <table>
601 <thead>
602+ <th> id </th>
603 <th> name </th>
604 <th> description </th>
605+ <th> address </th>
606 </thead>
607 <tbody>
608 {% for list in lists %}
609 <tr>
610- <td><a href="/discuss/mail/{{list.address}}">{{ list.address }}</a></td>
611+ <td><a href="/discuss/mail/{{list.id}}">{{ list.id }}</a></td>
612+ <td>{{ list.name }}</td>
613 <td>{{ list.description }}</td>
614+ <td>{{ list.address }}</td>
615 </tr>
616 {% endfor %}
617 </tbody>
618 diff --git a/themes/default/templates/post.html b/themes/default/templates/post.html
619new file mode 100644
620index 0000000..8826000
621--- /dev/null
622+++ b/themes/default/templates/post.html
623 @@ -0,0 +1,14 @@
624+ {% extends "base.html" %}
625+ {% block content %}
626+ <section class="thread-view">
627+ <article>
628+ <header>
629+ <b>From: {{ message.from_address }}</b></br>
630+ <b>To: ???</b></br>
631+ <b><a href="/discuss/mail/post/{{list_id}}/{{message.message_id}}">{{ message.message_id }}</a></b>
632+ <span class="right">{{ message.created_at | format_epoch }}</span>
633+ </header>
634+ <pre>{{ message.text }}</pre>
635+ </article>
636+ </section>
637+ {% endblock %}
638 diff --git a/themes/default/templates/thread.html b/themes/default/templates/thread.html
639index f6997b9..764f9b7 100644
640--- a/themes/default/templates/thread.html
641+++ b/themes/default/templates/thread.html
642 @@ -1,14 +1,16 @@
643 {% extends "base.html" %}
644 {% block content %}
645 <section class="thread-view">
646+
647+ {% for reply in messages %}
648 <article>
649- <header><b>{{ message.subject }}</b><span class="right">{{ message.created_at }}</span></header>
650- <pre class="email-thread">{{ message.body }}</pre>
651- </article>
652- {% for reply in replies %}
653- <article>
654- <header><b>{{ reply.subject }}</b><span class="right">{{ reply.created_at }}</span></header>
655- <pre>{{ reply.body }}</pre>
656+ <header>
657+ <b>From: {{ reply.from_address }}</b></br>
658+ <b>To: ???</b></br>
659+ <b><a href="/discuss/mail/post/{{list_id}}/{{reply.message_id}}">{{ reply.message_id }}</a></b>
660+ <span class="right">{{ reply.created_at | format_epoch }}</span>
661+ </header>
662+ <pre>{{ reply.text }}</pre>
663 </article>
664 {% endfor %}
665 </section>
666 diff --git a/themes/default/templates/threads.html b/themes/default/templates/threads.html
667index 8e0cb4b..d7c7431 100644
668--- a/themes/default/templates/threads.html
669+++ b/themes/default/templates/threads.html
670 @@ -4,7 +4,7 @@
671 <section>
672 <article>
673 <header>
674- {{ macros::navigation(items=discnav, title=address) }}
675+ {{ macros::navigation(items=discnav, title=list.id) }}
676 </header>
677 <table>
678 <thead>
679 @@ -16,9 +16,9 @@
680 <tbody>
681 {% for thread in threads %}
682 <tr>
683- <td><a href="/discuss/mail/{{address}}/{{thread.id}}">{{ thread.from }}</a></td>
684- <td>{{thread.timestamp}}</td>
685- <td>{{thread.subject}}</td>
686+ <td>{{ thread.from }}</a></td>
687+ <td>{{thread.timestamp | format_epoch }}</td>
688+ <td><a href="/discuss/mail/{{list.id}}/{{thread.message_id}}">{{thread.subject}}</a></td>
689 <td>{{thread.n_replies}}</td>
690 </tr>
691 {% endfor %}