core: add subscribe confirmation generation and saving it to `out` queue
When a subscription request is received and accepted, a confirmation will be generated from either templates in the database or a default template, and placed in the `out` queue. Picking up outgoing email from the `out` queue has not been implemented yet.grcov
parent
4fbd009e5a
commit
26bd09d005
|
@ -1500,6 +1500,7 @@ dependencies = [
|
|||
"log",
|
||||
"mailin-embedded",
|
||||
"melib",
|
||||
"minijinja",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
|
@ -2120,6 +2121,7 @@ dependencies = [
|
|||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"serde_json",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
|
|
5
Makefile
5
Makefile
|
@ -4,8 +4,9 @@ check:
|
|||
|
||||
.PHONY: fmt
|
||||
fmt:
|
||||
cargo fmt --all
|
||||
cargo sort -w || true
|
||||
cargo +nightly fmt --all || cargo fmt --all
|
||||
cargo sort -w || printf "cargo-sort binary not found in PATH.\n"
|
||||
djhtml -i web/src/templates/* || printf "djhtml binary not found in PATH.\n"
|
||||
|
||||
.PHONY: lint
|
||||
lint:
|
||||
|
|
|
@ -16,7 +16,8 @@ chrono = { version = "^0.4", features = ["serde", ] }
|
|||
error-chain = { version = "0.12.4", default-features = false }
|
||||
log = "0.4"
|
||||
melib = { version = "*", default-features = false, features = ["smtp", "unicode_algorithms", "maildir_backend"], git = "https://github.com/meli/meli", rev = "2447a2c" }
|
||||
rusqlite = { version = "^0.28", features = ["bundled", "trace", "hooks"] }
|
||||
minijinja = { version = "0.31.0", features = ["source", ] }
|
||||
rusqlite = { version = "^0.28", features = ["bundled", "trace", "hooks", "serde_json", "array"] }
|
||||
serde = { version = "^1", features = ["derive", ] }
|
||||
serde_json = "^1"
|
||||
toml = "^0.5"
|
||||
|
|
|
@ -46,6 +46,10 @@ impl std::fmt::Debug for Connection {
|
|||
}
|
||||
}
|
||||
|
||||
mod templates;
|
||||
pub use templates::*;
|
||||
mod queue;
|
||||
pub use queue::*;
|
||||
mod error_queue;
|
||||
pub use error_queue::*;
|
||||
mod posts;
|
||||
|
@ -57,14 +61,18 @@ pub use policies::*;
|
|||
|
||||
fn log_callback(error_code: std::ffi::c_int, message: &str) {
|
||||
match error_code {
|
||||
rusqlite::ffi::SQLITE_NOTICE => log::info!("{}", message),
|
||||
rusqlite::ffi::SQLITE_WARNING => log::warn!("{}", message),
|
||||
rusqlite::ffi::SQLITE_OK
|
||||
| rusqlite::ffi::SQLITE_DONE
|
||||
| rusqlite::ffi::SQLITE_NOTICE
|
||||
| rusqlite::ffi::SQLITE_NOTICE_RECOVER_WAL
|
||||
| rusqlite::ffi::SQLITE_NOTICE_RECOVER_ROLLBACK => log::info!("{}", message),
|
||||
rusqlite::ffi::SQLITE_WARNING | rusqlite::ffi::SQLITE_WARNING_AUTOINDEX => {
|
||||
log::warn!("{}", message)
|
||||
}
|
||||
_ => log::error!("{error_code} {}", message),
|
||||
}
|
||||
}
|
||||
// INSERT INTO subscription(list, address, name, enabled, digest, verified,
|
||||
// hide_address, receive_duplicates, receive_own_posts, receive_confirmation)
|
||||
// VALUES
|
||||
|
||||
fn user_authorizer_callback(
|
||||
auth_context: rusqlite::hooks::AuthContext<'_>,
|
||||
) -> rusqlite::hooks::Authorization {
|
||||
|
@ -129,6 +137,7 @@ impl Connection {
|
|||
unsafe { rusqlite::trace::config_log(Some(log_callback)).unwrap() };
|
||||
});
|
||||
let conn = DbConnection::open(conf.db_path.to_str().unwrap())?;
|
||||
rusqlite::vtab::array::load_module(&conn)?;
|
||||
conn.set_db_config(DbConfig::SQLITE_DBCONFIG_ENABLE_FKEY, true)?;
|
||||
conn.set_db_config(DbConfig::SQLITE_DBCONFIG_ENABLE_TRIGGER, true)?;
|
||||
conn.set_db_config(DbConfig::SQLITE_DBCONFIG_DEFENSIVE, true)?;
|
||||
|
|
|
@ -155,7 +155,8 @@ impl Connection {
|
|||
let owners = self.list_owners(list.pk)?;
|
||||
trace!("List subscriptions {:#?}", &subscriptions);
|
||||
let mut list_ctx = ListContext {
|
||||
policy: self.list_policy(list.pk)?,
|
||||
post_policy: self.list_policy(list.pk)?,
|
||||
subscription_policy: self.list_subscription_policy(list.pk)?,
|
||||
list_owners: &owners,
|
||||
list: &mut list,
|
||||
subscriptions: &subscriptions,
|
||||
|
@ -246,8 +247,9 @@ impl Connection {
|
|||
list
|
||||
);
|
||||
|
||||
let list_policy = self.list_policy(list.pk)?;
|
||||
let approval_needed = list_policy
|
||||
let post_policy = self.list_policy(list.pk)?;
|
||||
let subscription_policy = self.list_subscription_policy(list.pk)?;
|
||||
let approval_needed = post_policy
|
||||
.as_ref()
|
||||
.map(|p| p.approval_needed)
|
||||
.unwrap_or(false);
|
||||
|
@ -275,7 +277,48 @@ impl Connection {
|
|||
} else if let Err(_err) = self.add_subscription(list.pk, subscription) {
|
||||
//FIXME: send failure notice to f
|
||||
} else {
|
||||
//FIXME: send success notice
|
||||
let templ = self
|
||||
.fetch_template(Template::SUBSCRIPTION_CONFIRMATION, Some(list.pk))?
|
||||
.map(DbVal::into_inner)
|
||||
.unwrap_or_else(Template::default_subscription_confirmation);
|
||||
|
||||
let mut confirmation = templ.render(minijinja::context! {
|
||||
list => &list,
|
||||
})?;
|
||||
confirmation.headers.insert(
|
||||
melib::HeaderName::new_unchecked("From"),
|
||||
list.request_subaddr(),
|
||||
);
|
||||
confirmation
|
||||
.headers
|
||||
.insert(melib::HeaderName::new_unchecked("To"), f.to_string());
|
||||
for (hdr, val) in [
|
||||
("List-Id", Some(list.id_header())),
|
||||
("List-Help", list.help_header()),
|
||||
("List-Post", list.post_header(post_policy.as_deref())),
|
||||
(
|
||||
"List-Unsubscribe",
|
||||
list.unsubscribe_header(subscription_policy.as_deref()),
|
||||
),
|
||||
(
|
||||
"List-Subscribe",
|
||||
list.subscribe_header(subscription_policy.as_deref()),
|
||||
),
|
||||
("List-Archive", list.archive_header()),
|
||||
] {
|
||||
if let Some(val) = val {
|
||||
confirmation
|
||||
.headers
|
||||
.insert(melib::HeaderName::new_unchecked(hdr), val);
|
||||
}
|
||||
}
|
||||
self.insert_to_queue(
|
||||
Queue::Out,
|
||||
Some(list.pk),
|
||||
None,
|
||||
confirmation.finalise()?.as_bytes(),
|
||||
String::new(),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,176 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
//! # Queues
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// In-database queues of mail.
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Debug, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Queue {
|
||||
/// Messages that have been submitted but not yet processed, await
|
||||
/// processing in the `maildrop` queue. Messages can be added to the
|
||||
/// `maildrop` queue even when mailpot is not running.
|
||||
Maildrop,
|
||||
/// List administrators may introduce rules for emails to be placed
|
||||
/// indefinitely in the `hold` queue. Messages placed in the `hold`
|
||||
/// queue stay there until the administrator intervenes. No periodic
|
||||
/// delivery attempts are made for messages in the `hold` queue.
|
||||
Hold,
|
||||
/// When all the deliverable recipients for a message are delivered, and for
|
||||
/// some recipients delivery failed for a transient reason (it might
|
||||
/// succeed later), the message is placed in the `deferred` queue.
|
||||
Deferred,
|
||||
/// Invalid received or generated e-mail saved for debug and troubleshooting
|
||||
/// reasons.
|
||||
Corrupt,
|
||||
/// Emails that must be sent as soon as possible.
|
||||
Out,
|
||||
}
|
||||
|
||||
impl Queue {
|
||||
/// Returns the name of the queue used in the database schema.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Maildrop => "maildrop",
|
||||
Self::Hold => "hold",
|
||||
Self::Deferred => "deferred",
|
||||
Self::Corrupt => "corrupt",
|
||||
Self::Out => "out",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Connection {
|
||||
/// Insert a received email into a queue.
|
||||
pub fn insert_to_queue(
|
||||
&self,
|
||||
queue: Queue,
|
||||
list_pk: Option<i64>,
|
||||
env: Option<Cow<'_, Envelope>>,
|
||||
raw: &[u8],
|
||||
comment: String,
|
||||
) -> Result<i64> {
|
||||
let env = env
|
||||
.map(Ok)
|
||||
.unwrap_or_else(|| melib::Envelope::from_bytes(raw, None).map(Cow::Owned))?;
|
||||
let mut stmt = self.connection.prepare(
|
||||
"INSERT INTO queue(which, list, comment, to_addresses, from_address, subject, \
|
||||
message_id, message) VALUES(?, ?, ?, ?, ?, ?, ?, ?) RETURNING pk;",
|
||||
)?;
|
||||
let pk = stmt.query_row(
|
||||
rusqlite::params![
|
||||
queue.as_str(),
|
||||
&list_pk,
|
||||
&comment,
|
||||
&env.field_to_to_string(),
|
||||
&env.field_from_to_string(),
|
||||
&env.subject(),
|
||||
&env.message_id().to_string(),
|
||||
raw,
|
||||
],
|
||||
|row| {
|
||||
let pk: i64 = row.get("pk")?;
|
||||
Ok(pk)
|
||||
},
|
||||
)?;
|
||||
Ok(pk)
|
||||
}
|
||||
|
||||
/// Fetch all queue entries.
|
||||
pub fn queue(&self, queue: Queue) -> Result<Vec<DbVal<Value>>> {
|
||||
let mut stmt = self
|
||||
.connection
|
||||
.prepare("SELECT * FROM queue WHERE which = ?;")?;
|
||||
let iter = stmt.query_map([&queue.as_str()], |row| {
|
||||
let pk = row.get::<_, i64>("pk")?;
|
||||
Ok(DbVal(
|
||||
json!({
|
||||
"pk" : pk,
|
||||
"comment": row.get::<_, Option<String>>("comment")?,
|
||||
"to_addresses": row.get::<_, String>("to_addresses")?,
|
||||
"from_address": row.get::<_, String>("from_address")?,
|
||||
"subject": row.get::<_, String>("subject")?,
|
||||
"message_id": row.get::<_, String>("message_id")?,
|
||||
"message": row.get::<_, Vec<u8>>("message")?,
|
||||
"timestamp": row.get::<_, u64>("timestamp")?,
|
||||
"datetime": row.get::<_, String>("datetime")?,
|
||||
}),
|
||||
pk,
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut ret = vec![];
|
||||
for item in iter {
|
||||
let item = item?;
|
||||
ret.push(item);
|
||||
}
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
/// Delete queue entries returning the deleted values.
|
||||
pub fn delete_from_queue(&mut self, queue: Queue, index: Vec<i64>) -> Result<Vec<Value>> {
|
||||
let tx = self.connection.transaction()?;
|
||||
|
||||
let cl = |row: &rusqlite::Row<'_>| {
|
||||
Ok(json!({
|
||||
"pk" : -1,
|
||||
"comment": row.get::<_, Option<String>>("comment")?,
|
||||
"to_addresses": row.get::<_, String>("to_addresses")?,
|
||||
"from_address": row.get::<_, String>("from_address")?,
|
||||
"subject": row.get::<_, String>("subject")?,
|
||||
"message_id": row.get::<_, String>("message_id")?,
|
||||
"message": row.get::<_, Vec<u8>>("message")?,
|
||||
"timestamp": row.get::<_, u64>("timestamp")?,
|
||||
"datetime": row.get::<_, String>("datetime")?,
|
||||
}))
|
||||
};
|
||||
let mut stmt = if index.is_empty() {
|
||||
tx.prepare("DELETE FROM queue WHERE which = ? RETURNING *;")?
|
||||
} else {
|
||||
tx.prepare("DELETE FROM queue WHERE which = ? AND pk IN rarray(?) RETURNING *;")?
|
||||
};
|
||||
let iter = if index.is_empty() {
|
||||
stmt.query_map([&queue.as_str()], cl)?
|
||||
} else {
|
||||
// Note: A `Rc<Vec<Value>>` must be used as the parameter.
|
||||
let index = std::rc::Rc::new(
|
||||
index
|
||||
.into_iter()
|
||||
.map(rusqlite::types::Value::from)
|
||||
.collect::<Vec<rusqlite::types::Value>>(),
|
||||
);
|
||||
stmt.query_map(rusqlite::params![queue.as_str(), index], cl)?
|
||||
};
|
||||
|
||||
let mut ret = vec![];
|
||||
for item in iter {
|
||||
let item = item?;
|
||||
ret.push(item);
|
||||
}
|
||||
drop(stmt);
|
||||
tx.commit()?;
|
||||
Ok(ret)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,176 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
//! Named templates, for generated e-mail like confirmations, alerts etc.
|
||||
|
||||
use super::*;
|
||||
|
||||
impl Connection {
|
||||
/// Fetch all.
|
||||
pub fn fetch_templates(&self) -> Result<Vec<DbVal<Template>>> {
|
||||
let mut stmt = self.connection.prepare("SELECT * FROM templates;")?;
|
||||
let iter = stmt.query_map(rusqlite::params![], |row| {
|
||||
let pk = row.get("pk")?;
|
||||
Ok(DbVal(
|
||||
Template {
|
||||
pk,
|
||||
name: row.get("name")?,
|
||||
list: row.get("list")?,
|
||||
subject: row.get("subject")?,
|
||||
headers_json: row.get("headers_json")?,
|
||||
body: row.get("body")?,
|
||||
},
|
||||
pk,
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut ret = vec![];
|
||||
for templ in iter {
|
||||
let templ = templ?;
|
||||
ret.push(templ);
|
||||
}
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
/// Fetch a named template.
|
||||
pub fn fetch_template(
|
||||
&self,
|
||||
template: &str,
|
||||
list_pk: Option<i64>,
|
||||
) -> Result<Option<DbVal<Template>>> {
|
||||
let mut stmt = self
|
||||
.connection
|
||||
.prepare("SELECT * FROM templates WHERE name = ? AND list IS ?;")?;
|
||||
let ret = stmt
|
||||
.query_row(rusqlite::params![&template, &list_pk], |row| {
|
||||
let pk = row.get("pk")?;
|
||||
Ok(DbVal(
|
||||
Template {
|
||||
pk,
|
||||
name: row.get("name")?,
|
||||
list: row.get("list")?,
|
||||
subject: row.get("subject")?,
|
||||
headers_json: row.get("headers_json")?,
|
||||
body: row.get("body")?,
|
||||
},
|
||||
pk,
|
||||
))
|
||||
})
|
||||
.optional()?;
|
||||
if ret.is_none() && list_pk.is_some() {
|
||||
let mut stmt = self
|
||||
.connection
|
||||
.prepare("SELECT * FROM templates WHERE name = ? AND list IS NULL;")?;
|
||||
Ok(stmt
|
||||
.query_row(rusqlite::params![&template], |row| {
|
||||
let pk = row.get("pk")?;
|
||||
Ok(DbVal(
|
||||
Template {
|
||||
pk,
|
||||
name: row.get("name")?,
|
||||
list: row.get("list")?,
|
||||
subject: row.get("subject")?,
|
||||
headers_json: row.get("headers_json")?,
|
||||
body: row.get("body")?,
|
||||
},
|
||||
pk,
|
||||
))
|
||||
})
|
||||
.optional()?)
|
||||
} else {
|
||||
Ok(ret)
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a named template.
|
||||
pub fn add_template(&self, template: Template) -> Result<DbVal<Template>> {
|
||||
let mut stmt = self.connection.prepare(
|
||||
"INSERT INTO templates(name, list, subject, headers_json, body) VALUES(?, ?, ?, ?, ?) \
|
||||
RETURNING *;",
|
||||
)?;
|
||||
let ret = stmt
|
||||
.query_row(
|
||||
rusqlite::params![
|
||||
&template.name,
|
||||
&template.list,
|
||||
&template.subject,
|
||||
&template.headers_json,
|
||||
&template.body
|
||||
],
|
||||
|row| {
|
||||
let pk = row.get("pk")?;
|
||||
Ok(DbVal(
|
||||
Template {
|
||||
pk,
|
||||
name: row.get("name")?,
|
||||
list: row.get("list")?,
|
||||
subject: row.get("subject")?,
|
||||
headers_json: row.get("headers_json")?,
|
||||
body: row.get("body")?,
|
||||
},
|
||||
pk,
|
||||
))
|
||||
},
|
||||
)
|
||||
.map_err(|err| {
|
||||
if matches!(
|
||||
err,
|
||||
rusqlite::Error::SqliteFailure(
|
||||
rusqlite::ffi::Error {
|
||||
code: rusqlite::ffi::ErrorCode::ConstraintViolation,
|
||||
extended_code: 787
|
||||
},
|
||||
_
|
||||
)
|
||||
) {
|
||||
Error::from(err).chain_err(|| NotFound("Could not find a list with this pk."))
|
||||
} else {
|
||||
err.into()
|
||||
}
|
||||
})?;
|
||||
|
||||
trace!("add_template {:?}.", &ret);
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
/// Remove a named template.
|
||||
pub fn remove_template(&self, template: &str, list_pk: Option<i64>) -> Result<Template> {
|
||||
let mut stmt = self
|
||||
.connection
|
||||
.prepare("DELETE FROM templates WHERE name = ? AND list IS ? RETURNING *;")?;
|
||||
let ret = stmt.query_row(rusqlite::params![&template, &list_pk], |row| {
|
||||
Ok(Template {
|
||||
pk: -1,
|
||||
name: row.get("name")?,
|
||||
list: row.get("list")?,
|
||||
subject: row.get("subject")?,
|
||||
headers_json: row.get("headers_json")?,
|
||||
body: row.get("body")?,
|
||||
})
|
||||
})?;
|
||||
|
||||
trace!(
|
||||
"remove_template {} list_pk {:?} {:?}.",
|
||||
template,
|
||||
&list_pk,
|
||||
&ret
|
||||
);
|
||||
Ok(ret)
|
||||
}
|
||||
}
|
|
@ -63,5 +63,6 @@ error_chain! {
|
|||
Io(::std::io::Error) #[doc="Error returned from internal I/O operations."];
|
||||
Melib(melib::error::Error) #[doc="Error returned from e-mail protocol operations from `melib` crate."];
|
||||
SerdeJson(serde_json::Error) #[doc="Error from deserializing JSON values."];
|
||||
Template(minijinja::Error) #[doc="Error returned from minijinja template engine."];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -56,7 +56,9 @@ pub struct ListContext<'list> {
|
|||
/// The mailing list subscriptions.
|
||||
pub subscriptions: &'list [DbVal<ListSubscription>],
|
||||
/// The mailing list post policy.
|
||||
pub policy: Option<DbVal<PostPolicy>>,
|
||||
pub post_policy: Option<DbVal<PostPolicy>>,
|
||||
/// The mailing list subscription policy.
|
||||
pub subscription_policy: Option<DbVal<SubscriptionPolicy>>,
|
||||
/// The scheduled jobs added by each filter in a list's
|
||||
/// [`PostFilter`](message_filters::PostFilter) stack.
|
||||
pub scheduled_jobs: Vec<MailJob>,
|
||||
|
|
|
@ -63,7 +63,7 @@ impl PostFilter for PostRightsCheck {
|
|||
ctx: &'p mut ListContext<'list>,
|
||||
) -> std::result::Result<(&'p mut Post, &'p mut ListContext<'list>), ()> {
|
||||
trace!("Running PostRightsCheck filter");
|
||||
if let Some(ref policy) = ctx.policy {
|
||||
if let Some(ref policy) = ctx.post_policy {
|
||||
if policy.announce_only {
|
||||
trace!("post policy is announce_only");
|
||||
let owner_addresses = ctx
|
||||
|
@ -142,22 +142,33 @@ impl PostFilter for AddListHeaders {
|
|||
) -> std::result::Result<(&'p mut Post, &'p mut ListContext<'list>), ()> {
|
||||
trace!("Running AddListHeaders filter");
|
||||
let (mut headers, body) = melib::email::parser::mail(&post.bytes).unwrap();
|
||||
let list_id = ctx.list.display_name();
|
||||
let sender = format!("<{}>", ctx.list.address);
|
||||
headers.push((&b"List-ID"[..], list_id.as_bytes()));
|
||||
headers.push((&b"Sender"[..], sender.as_bytes()));
|
||||
let list_post = ctx.list.post_header();
|
||||
let list_unsubscribe = ctx.list.unsubscription_header();
|
||||
|
||||
let list_id = Some(ctx.list.id_header());
|
||||
let list_help = ctx.list.help_header();
|
||||
let list_post = ctx.list.post_header(ctx.post_policy.as_deref());
|
||||
let list_unsubscribe = ctx
|
||||
.list
|
||||
.unsubscribe_header(ctx.subscription_policy.as_deref());
|
||||
let list_subscribe = ctx
|
||||
.list
|
||||
.subscribe_header(ctx.subscription_policy.as_deref());
|
||||
let list_archive = ctx.list.archive_header();
|
||||
if let Some(post) = list_post.as_ref() {
|
||||
headers.push((&b"List-Post"[..], post.as_bytes()));
|
||||
}
|
||||
if let Some(unsubscribe) = list_unsubscribe.as_ref() {
|
||||
headers.push((&b"List-Unsubscribe"[..], unsubscribe.as_bytes()));
|
||||
}
|
||||
if let Some(archive) = list_archive.as_ref() {
|
||||
headers.push((&b"List-Archive"[..], archive.as_bytes()));
|
||||
|
||||
for (hdr, val) in [
|
||||
(b"List-Id".as_slice(), &list_id),
|
||||
(b"List-Help".as_slice(), &list_help),
|
||||
(b"List-Post".as_slice(), &list_post),
|
||||
(b"List-Unsubscribe".as_slice(), &list_unsubscribe),
|
||||
(b"List-Subscribe".as_slice(), &list_subscribe),
|
||||
(b"List-Archive".as_slice(), &list_archive),
|
||||
] {
|
||||
if let Some(val) = val {
|
||||
headers.push((hdr, val.as_bytes()));
|
||||
}
|
||||
}
|
||||
|
||||
let mut new_vec = Vec::with_capacity(
|
||||
headers
|
||||
.iter()
|
||||
|
|
|
@ -36,6 +36,12 @@ impl<T> DbVal<T> {
|
|||
pub fn pk(&self) -> i64 {
|
||||
self.1
|
||||
}
|
||||
|
||||
/// Unwrap inner value.
|
||||
#[inline(always)]
|
||||
pub fn into_inner(self) -> T {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> std::ops::Deref for DbVal<T> {
|
||||
|
@ -102,22 +108,86 @@ impl MailingList {
|
|||
format!("\"{}\" <{}>", self.name, self.address)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
/// Request subaddress.
|
||||
pub fn request_subaddr(&self) -> String {
|
||||
let p = self.address.split('@').collect::<Vec<&str>>();
|
||||
format!("{}+request@{}", p[0], p[1])
|
||||
}
|
||||
|
||||
/// Value of `List-Id` header.
|
||||
///
|
||||
/// See RFC2919 Section 3: <https://www.rfc-editor.org/rfc/rfc2919>
|
||||
pub fn id_header(&self) -> String {
|
||||
let p = self.address.split('@').collect::<Vec<&str>>();
|
||||
format!(
|
||||
"{}{}<{}.{}>",
|
||||
self.description.as_deref().unwrap_or(""),
|
||||
self.description.as_ref().map(|_| " ").unwrap_or(""),
|
||||
self.id,
|
||||
p[1]
|
||||
)
|
||||
}
|
||||
|
||||
/// Value of `List-Help` header.
|
||||
///
|
||||
/// See RFC2369 Section 3.1: <https://www.rfc-editor.org/rfc/rfc2369#section-3.1>
|
||||
pub fn help_header(&self) -> Option<String> {
|
||||
Some(format!("<mailto:{}?subject=help>", self.request_subaddr()))
|
||||
}
|
||||
|
||||
/// Value of `List-Post` header.
|
||||
///
|
||||
/// See RFC2369 Section 3.4: <https://www.rfc-editor.org/rfc/rfc2369#section-3.4>
|
||||
pub fn post_header(&self) -> Option<String> {
|
||||
Some(format!("<mailto:{}>", self.address))
|
||||
pub fn post_header(&self, policy: Option<&PostPolicy>) -> Option<String> {
|
||||
Some(policy.map_or_else(
|
||||
|| "NO".to_string(),
|
||||
|p| {
|
||||
if p.announce_only {
|
||||
"NO".to_string()
|
||||
} else {
|
||||
format!("<mailto:{}>", self.address)
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// Value of `List-Unsubscribe` header.
|
||||
///
|
||||
/// See RFC2369 Section 3.2: <https://www.rfc-editor.org/rfc/rfc2369#section-3.2>
|
||||
pub fn unsubscription_header(&self) -> Option<String> {
|
||||
let p = self.address.split('@').collect::<Vec<&str>>();
|
||||
Some(format!(
|
||||
"<mailto:{}+request@{}?subject=subscribe>",
|
||||
p[0], p[1]
|
||||
))
|
||||
pub fn unsubscribe_header(&self, policy: Option<&SubscriptionPolicy>) -> Option<String> {
|
||||
policy.map_or_else(
|
||||
|| None,
|
||||
|p| {
|
||||
if p.open {
|
||||
None
|
||||
} else {
|
||||
Some(format!(
|
||||
"<mailto:{}?subject=unsubscribe>",
|
||||
self.request_subaddr()
|
||||
))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Value of `List-Subscribe` header.
|
||||
///
|
||||
/// See RFC2369 Section 3.3: <https://www.rfc-editor.org/rfc/rfc2369#section-3.3>
|
||||
pub fn subscribe_header(&self, policy: Option<&SubscriptionPolicy>) -> Option<String> {
|
||||
policy.map_or_else(
|
||||
|| None,
|
||||
|p| {
|
||||
if p.open {
|
||||
None
|
||||
} else {
|
||||
Some(format!(
|
||||
"<mailto:{}?subject=subscribe>",
|
||||
self.request_subaddr()
|
||||
))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Value of `List-Archive` header.
|
||||
|
@ -134,18 +204,16 @@ impl MailingList {
|
|||
|
||||
/// List unsubscribe action as a [`MailtoAddress`](super::MailtoAddress).
|
||||
pub fn unsubscription_mailto(&self) -> MailtoAddress {
|
||||
let p = self.address.split('@').collect::<Vec<&str>>();
|
||||
MailtoAddress {
|
||||
address: format!("{}+request@{}", p[0], p[1]),
|
||||
address: self.request_subaddr(),
|
||||
subject: Some("unsubscribe".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// List subscribe action as a [`MailtoAddress`](super::MailtoAddress).
|
||||
pub fn subscription_mailto(&self) -> MailtoAddress {
|
||||
let p = self.address.split('@').collect::<Vec<&str>>();
|
||||
MailtoAddress {
|
||||
address: format!("{}+request@{}", p[0], p[1]),
|
||||
address: self.request_subaddr(),
|
||||
subject: Some("subscribe".to_string()),
|
||||
}
|
||||
}
|
||||
|
@ -379,3 +447,114 @@ impl std::fmt::Display for Account {
|
|||
write!(fmt, "{:?}", self)
|
||||
}
|
||||
}
|
||||
|
||||
/// A named template.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct Template {
|
||||
/// Database primary key.
|
||||
pub pk: i64,
|
||||
/// Name.
|
||||
pub name: String,
|
||||
/// Associated list foreign key, optional.
|
||||
pub list: Option<i64>,
|
||||
/// Subject template.
|
||||
pub subject: Option<String>,
|
||||
/// Extra headers template.
|
||||
pub headers_json: Option<serde_json::Value>,
|
||||
/// Body template.
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Template {
|
||||
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(fmt, "{:?}", self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Template {
|
||||
/// Template name for generic failure e-mail.
|
||||
pub const GENERIC_FAILURE: &str = "generic-failure";
|
||||
/// Template name for generic success e-mail.
|
||||
pub const GENERIC_SUCCESS: &str = "generic-success";
|
||||
/// Template name for subscription confirmation e-mail.
|
||||
pub const SUBSCRIPTION_CONFIRMATION: &str = "subscription-confirmation";
|
||||
/// Template name for unsubscription confirmation e-mail.
|
||||
pub const UNSUBSCRIPTION_CONFIRMATION: &str = "unsubscription-confirmation";
|
||||
|
||||
/// Render a message body from a saved named template.
|
||||
pub fn render(&self, context: minijinja::value::Value) -> Result<melib::Draft> {
|
||||
use melib::{Draft, HeaderName};
|
||||
|
||||
let env = minijinja::Environment::new();
|
||||
let mut draft: Draft = Draft {
|
||||
body: env.render_named_str("body", &self.body, &context)?,
|
||||
..Draft::default()
|
||||
};
|
||||
if let Some(ref subject) = self.subject {
|
||||
draft.headers.insert(
|
||||
HeaderName::new_unchecked("Subject"),
|
||||
env.render_named_str("subject", subject, &context)?,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(draft)
|
||||
}
|
||||
|
||||
/// Template name for generic failure e-mail.
|
||||
pub fn default_generic_failure() -> Self {
|
||||
Self {
|
||||
pk: -1,
|
||||
name: Self::GENERIC_FAILURE.to_string(),
|
||||
list: None,
|
||||
subject: Some("Your e-mail was not processed successfully.".to_string()),
|
||||
headers_json: None,
|
||||
body: "{{ details|safe if details else \"\" }}".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a plain template for generic success e-mails.
|
||||
pub fn default_generic_success() -> Self {
|
||||
Self {
|
||||
pk: -1,
|
||||
name: Self::GENERIC_SUCCESS.to_string(),
|
||||
list: None,
|
||||
subject: Some("Your e-mail was processed successfully.".to_string()),
|
||||
headers_json: None,
|
||||
body: "{{ details|safe if details else \"\" }}".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a plain template for subscription confirmation.
|
||||
pub fn default_subscription_confirmation() -> Self {
|
||||
Self {
|
||||
pk: -1,
|
||||
name: Self::SUBSCRIPTION_CONFIRMATION.to_string(),
|
||||
list: None,
|
||||
subject: Some(
|
||||
"{% if list and (list.id or list.name) %}{% if list.id %}[{{ list.id }}] {% endif \
|
||||
%}You have successfully subscribed to {{ list.name if list.name else list.id \
|
||||
}}{% else %}You have successfully subscribed to this list{% endif %}."
|
||||
.to_string(),
|
||||
),
|
||||
headers_json: None,
|
||||
body: "{{ details|safe if details else \"\" }}".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a plain template for unsubscription confirmations.
|
||||
pub fn default_unsubscription_confirmation() -> Self {
|
||||
Self {
|
||||
pk: -1,
|
||||
name: Self::UNSUBSCRIPTION_CONFIRMATION.to_string(),
|
||||
list: None,
|
||||
subject: Some(
|
||||
"{% if list and (list.id or list.name) %}{% if list.id %}[{{ list.id }}] {% endif \
|
||||
%}You have successfully unsubscribed from {{ list.name if list.name else list.id \
|
||||
}}{% else %}You have successfully unsubscribed from this list{% endif %}."
|
||||
.to_string(),
|
||||
),
|
||||
headers_json: None,
|
||||
body: "{{ details|safe if details else \"\" }}".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -114,11 +114,15 @@ CREATE TABLE IF NOT EXISTS post (
|
|||
|
||||
CREATE TABLE IF NOT EXISTS templates (
|
||||
pk INTEGER PRIMARY KEY NOT NULL,
|
||||
list INTEGER UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
list INTEGER,
|
||||
subject TEXT,
|
||||
headers_json TEXT,
|
||||
body TEXT NOT NULL,
|
||||
created INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
last_modified INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
|
||||
UNIQUE (list, name) ON CONFLICT ROLLBACK
|
||||
);
|
||||
|
||||
-- # Queues
|
||||
|
|
|
@ -124,11 +124,15 @@ CREATE TABLE IF NOT EXISTS post (
|
|||
|
||||
CREATE TABLE IF NOT EXISTS templates (
|
||||
pk INTEGER PRIMARY KEY NOT NULL,
|
||||
list INTEGER UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
list INTEGER,
|
||||
subject TEXT,
|
||||
headers_json TEXT,
|
||||
body TEXT NOT NULL,
|
||||
created INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
last_modified INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
|
||||
UNIQUE (list, name) ON CONFLICT ROLLBACK
|
||||
);
|
||||
|
||||
-- # Queues
|
||||
|
|
|
@ -0,0 +1,186 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
mod utils;
|
||||
|
||||
use mailpot::{models::*, Configuration, Connection, Queue, SendMail};
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_template_replies() {
|
||||
utils::init_stderr_logging();
|
||||
|
||||
let tmp_dir = TempDir::new().unwrap();
|
||||
|
||||
let db_path = tmp_dir.path().join("mpot.db");
|
||||
let config = Configuration {
|
||||
send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
|
||||
db_path,
|
||||
data_path: tmp_dir.path().to_path_buf(),
|
||||
};
|
||||
|
||||
let mut db = Connection::open_or_create_db(config).unwrap().trusted();
|
||||
assert!(db.lists().unwrap().is_empty());
|
||||
let foo_chat = db
|
||||
.create_list(MailingList {
|
||||
pk: 0,
|
||||
name: "foobar chat".into(),
|
||||
id: "foo-chat".into(),
|
||||
address: "foo-chat@example.com".into(),
|
||||
description: None,
|
||||
archive_url: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(foo_chat.pk(), 1);
|
||||
let lists = db.lists().unwrap();
|
||||
assert_eq!(lists.len(), 1);
|
||||
assert_eq!(lists[0], foo_chat);
|
||||
let post_policy = db
|
||||
.set_list_policy(PostPolicy {
|
||||
pk: 0,
|
||||
list: foo_chat.pk(),
|
||||
announce_only: false,
|
||||
subscription_only: true,
|
||||
approval_needed: false,
|
||||
open: false,
|
||||
custom: false,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(post_policy.pk(), 1);
|
||||
assert_eq!(db.error_queue().unwrap().len(), 0);
|
||||
assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 0);
|
||||
|
||||
/* create custom subscribe confirm template, and check that it is used in
|
||||
* action */
|
||||
let _templ = db
|
||||
.add_template(Template {
|
||||
pk: 0,
|
||||
name: Template::SUBSCRIPTION_CONFIRMATION.into(),
|
||||
list: None,
|
||||
subject: Some("You have subscribed to {{ list.name }}".into()),
|
||||
headers_json: None,
|
||||
body: "You have subscribed to {{ list.name }}".into(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
/* subscribe first */
|
||||
|
||||
let bytes = b"From: Name <user@example.com>
|
||||
To: <foo-chat+subscribe@example.com>
|
||||
Subject: subscribe
|
||||
Date: Thu, 29 Oct 2020 13:58:16 +0000
|
||||
Message-ID:
|
||||
<PS1PR0601MB36750BD00EA89E1482FA98A2D5140_2@PS1PR0601MB3675.apcprd06.prod.outlook.com>
|
||||
Content-Language: en-US
|
||||
Content-Type: text/html
|
||||
Content-Transfer-Encoding: base64
|
||||
MIME-Version: 1.0
|
||||
|
||||
";
|
||||
let subenvelope = melib::Envelope::from_bytes(bytes, None).expect("Could not parse message");
|
||||
db.post(&subenvelope, bytes, /* dry_run */ false).unwrap();
|
||||
assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 1);
|
||||
assert_eq!(db.error_queue().unwrap().len(), 0);
|
||||
|
||||
let out_queue = db.queue(Queue::Out).unwrap();
|
||||
assert_eq!(out_queue.len(), 1);
|
||||
let out = &out_queue[0];
|
||||
let out_bytes = serde_json::from_value::<Vec<u8>>(out["message"].clone()).unwrap();
|
||||
let out_env = melib::Envelope::from_bytes(&out_bytes, None).unwrap();
|
||||
// eprintln!("{}", String::from_utf8_lossy(&out_bytes));
|
||||
assert_eq!(
|
||||
&out_env.from()[0].get_email(),
|
||||
"foo-chat+request@example.com",
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
out_env.to()[0].get_display_name().as_deref(),
|
||||
out_env.to()[0].get_email().as_str()
|
||||
),
|
||||
(Some("Name"), "user@example.com"),
|
||||
);
|
||||
assert_eq!(
|
||||
&out["subject"],
|
||||
&format!("You have subscribed to {}", foo_chat.name)
|
||||
);
|
||||
|
||||
/* then unsubscribe, remove custom template and subscribe again */
|
||||
|
||||
let unbytes = b"From: Name <user@example.com>
|
||||
To: <foo-chat+request@example.com>
|
||||
Subject: unsubscribe
|
||||
Date: Thu, 29 Oct 2020 13:58:17 +0000
|
||||
Message-ID:
|
||||
<PS1PR0601MB36750BD00EA89E1482FA98A2D5140_3@PS1PR0601MB3675.apcprd06.prod.outlook.com>
|
||||
Content-Language: en-US
|
||||
Content-Type: text/html
|
||||
Content-Transfer-Encoding: base64
|
||||
MIME-Version: 1.0
|
||||
|
||||
";
|
||||
let envelope = melib::Envelope::from_bytes(unbytes, None).expect("Could not parse message");
|
||||
db.post(&envelope, unbytes, /* dry_run */ false).unwrap();
|
||||
assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 0);
|
||||
assert_eq!(db.error_queue().unwrap().len(), 0);
|
||||
|
||||
let out_queue = db.queue(Queue::Out).unwrap();
|
||||
assert_eq!(out_queue.len(), 1);
|
||||
|
||||
let mut _templ = _templ.into_inner();
|
||||
let _templ2 = db
|
||||
.remove_template(Template::SUBSCRIPTION_CONFIRMATION, None)
|
||||
.unwrap();
|
||||
_templ.pk = _templ2.pk;
|
||||
assert_eq!(_templ, _templ2);
|
||||
|
||||
/* now this template should be used: */
|
||||
// let default_templ = Template::default_subscription_confirmation();
|
||||
|
||||
db.post(&subenvelope, bytes, /* dry_run */ false).unwrap();
|
||||
assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 1);
|
||||
assert_eq!(db.error_queue().unwrap().len(), 0);
|
||||
|
||||
let out_queue = db.queue(Queue::Out).unwrap();
|
||||
|
||||
assert_eq!(out_queue.len(), 2);
|
||||
let out = &out_queue[1];
|
||||
let out_bytes = serde_json::from_value::<Vec<u8>>(out["message"].clone()).unwrap();
|
||||
let out_env = melib::Envelope::from_bytes(&out_bytes, None).unwrap();
|
||||
// eprintln!("{}", String::from_utf8_lossy(&out_bytes));
|
||||
assert_eq!(
|
||||
&out_env.from()[0].get_email(),
|
||||
"foo-chat+request@example.com",
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
out_env.to()[0].get_display_name().as_deref(),
|
||||
out_env.to()[0].get_email().as_str()
|
||||
),
|
||||
(Some("Name"), "user@example.com"),
|
||||
);
|
||||
assert_eq!(
|
||||
out["subject"].as_str().unwrap(),
|
||||
&format!(
|
||||
"[{}] You have successfully subscribed to {}.",
|
||||
foo_chat.id, foo_chat.name
|
||||
)
|
||||
);
|
||||
}
|
|
@ -27,7 +27,7 @@ use super::*;
|
|||
const TOKEN_KEY: &str = "ssh_challenge";
|
||||
const EXPIRY_IN_SECS: i64 = 6 * 60;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, PartialOrd)]
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, Eq, PartialEq, PartialOrd)]
|
||||
pub enum Role {
|
||||
User,
|
||||
Admin,
|
||||
|
@ -114,17 +114,18 @@ pub async fn ssh_signin(
|
|||
None
|
||||
};
|
||||
|
||||
let (token, timestamp): (String, i64) = if let Some(tok) = prev_token {
|
||||
tok
|
||||
} else {
|
||||
use rand::{distributions::Alphanumeric, thread_rng, Rng};
|
||||
let (token, timestamp): (String, i64) = prev_token.map_or_else(
|
||||
|| {
|
||||
use rand::{distributions::Alphanumeric, thread_rng, Rng};
|
||||
|
||||
let mut rng = thread_rng();
|
||||
let chars: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect();
|
||||
println!("Random chars: {}", chars);
|
||||
session.insert(TOKEN_KEY, (&chars, now)).unwrap();
|
||||
(chars, now)
|
||||
};
|
||||
let mut rng = thread_rng();
|
||||
let chars: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect();
|
||||
println!("Random chars: {}", chars);
|
||||
session.insert(TOKEN_KEY, (&chars, now)).unwrap();
|
||||
(chars, now)
|
||||
},
|
||||
|tok| tok,
|
||||
);
|
||||
let timeout_left = ((timestamp + EXPIRY_IN_SECS) - now) as f64 / 60.0;
|
||||
|
||||
let root_url_prefix = &state.root_url_prefix;
|
||||
|
@ -190,11 +191,7 @@ pub async fn ssh_signin_post(
|
|||
"{}{}{}",
|
||||
state.root_url_prefix,
|
||||
LoginPath.to_uri(),
|
||||
if let Some(ref next) = next.next {
|
||||
next.as_str()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
next.next.as_ref().map_or("", |next| next.as_str())
|
||||
)));
|
||||
} else {
|
||||
tok
|
||||
|
@ -208,11 +205,7 @@ pub async fn ssh_signin_post(
|
|||
"{}{}{}",
|
||||
state.root_url_prefix,
|
||||
LoginPath.to_uri(),
|
||||
if let Some(ref next) = next.next {
|
||||
next.as_str()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
next.next.as_ref().map_or("", |next| next.as_str())
|
||||
)));
|
||||
};
|
||||
|
||||
|
@ -422,11 +415,8 @@ pub mod auth_request {
|
|||
T: RangeBounds<Role> + Clone + Send + Sync,
|
||||
{
|
||||
fn contains(&self, role: Option<Role>) -> bool {
|
||||
if let Some(role) = role {
|
||||
RangeBounds::contains(self, &role)
|
||||
} else {
|
||||
role.is_none()
|
||||
}
|
||||
role.as_ref()
|
||||
.map_or_else(|| role.is_none(), |role| RangeBounds::contains(self, role))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -483,8 +473,9 @@ pub mod auth_request {
|
|||
|
||||
_ => {
|
||||
let unauthorized_response = if let Some(ref login_url) = self.login_url {
|
||||
let url: Cow<'static, str> =
|
||||
if let Some(ref next) = self.redirect_field_name {
|
||||
let url: Cow<'static, str> = self.redirect_field_name.as_ref().map_or_else(
|
||||
|| login_url.as_ref().clone(),
|
||||
|next| {
|
||||
format!(
|
||||
"{login_url}?{next}={}",
|
||||
percent_encoding::utf8_percent_encode(
|
||||
|
@ -493,9 +484,9 @@ pub mod auth_request {
|
|||
)
|
||||
)
|
||||
.into()
|
||||
} else {
|
||||
login_url.as_ref().clone()
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
Response::builder()
|
||||
.status(http::StatusCode::TEMPORARY_REDIRECT)
|
||||
.header(http::header::LOCATION, url.as_ref())
|
||||
|
|
|
@ -79,12 +79,11 @@ pub fn calendarize_with_offset(date: NaiveDate, offset: u32) -> Vec<[u32; 7]> {
|
|||
.unwrap()
|
||||
.weekday()
|
||||
.num_days_from_sunday();
|
||||
let mut first_date_day;
|
||||
if num_days_from_sunday < offset {
|
||||
first_date_day = num_days_from_sunday + (7 - offset);
|
||||
let mut first_date_day = if num_days_from_sunday < offset {
|
||||
num_days_from_sunday + (7 - offset)
|
||||
} else {
|
||||
first_date_day = num_days_from_sunday - offset;
|
||||
}
|
||||
num_days_from_sunday - offset
|
||||
};
|
||||
let end_date = NaiveDate::from_ymd_opt(year, month + 1, 1)
|
||||
.unwrap_or_else(|| NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap())
|
||||
.pred_opt()
|
||||
|
|
|
@ -17,6 +17,35 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#![deny(
|
||||
//missing_docs,
|
||||
rustdoc::broken_intra_doc_links,
|
||||
/* groups */
|
||||
clippy::correctness,
|
||||
clippy::suspicious,
|
||||
clippy::complexity,
|
||||
clippy::perf,
|
||||
clippy::style,
|
||||
clippy::cargo,
|
||||
clippy::nursery,
|
||||
/* restriction */
|
||||
clippy::dbg_macro,
|
||||
clippy::rc_buffer,
|
||||
clippy::as_underscore,
|
||||
clippy::assertions_on_result_states,
|
||||
/* pedantic */
|
||||
clippy::cast_lossless,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::ptr_as_ptr,
|
||||
clippy::bool_to_int_with_if,
|
||||
clippy::borrow_as_ptr,
|
||||
clippy::case_sensitive_file_extension_comparisons,
|
||||
clippy::cast_lossless,
|
||||
clippy::cast_ptr_alignment,
|
||||
clippy::naive_bytecount
|
||||
)]
|
||||
#![allow(clippy::multiple_crate_versions, clippy::missing_const_for_fn)]
|
||||
|
||||
pub use axum::{
|
||||
extract::{Path, Query, State},
|
||||
handler::Handler,
|
||||
|
@ -54,12 +83,14 @@ use tokio::sync::RwLock;
|
|||
|
||||
pub mod auth;
|
||||
pub mod cal;
|
||||
pub mod minijinja_utils;
|
||||
pub mod settings;
|
||||
pub mod typed_paths;
|
||||
pub mod utils;
|
||||
|
||||
pub use auth::*;
|
||||
pub use cal::{calendarize, *};
|
||||
pub use minijinja_utils::*;
|
||||
pub use settings::*;
|
||||
pub use typed_paths::{tsr::RouterExt, *};
|
||||
pub use utils::*;
|
||||
|
|
|
@ -0,0 +1,358 @@
|
|||
/*
|
||||
* 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 super::*;
|
||||
|
||||
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,
|
||||
login_path,
|
||||
logout_path,
|
||||
settings_path,
|
||||
help_path,
|
||||
list_path,
|
||||
list_settings_path,
|
||||
list_edit_path,
|
||||
list_post_path
|
||||
);
|
||||
add!(filter pluralize);
|
||||
env.set_source(Source::from_path("web/src/templates/"));
|
||||
|
||||
env
|
||||
};
|
||||
}
|
||||
|
||||
pub trait StripCarets {
|
||||
fn strip_carets(&self) -> &str;
|
||||
}
|
||||
|
||||
impl StripCarets for &str {
|
||||
fn strip_carets(&self) -> &str {
|
||||
let mut self_ref = self.trim();
|
||||
if self_ref.starts_with('<') && self_ref.ends_with('>') {
|
||||
self_ref = &self_ref[1..self_ref.len().saturating_sub(1)];
|
||||
}
|
||||
self_ref
|
||||
}
|
||||
}
|
||||
|
||||
#[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>,
|
||||
#[serde(serialize_with = "super::utils::to_safe_string_opt")]
|
||||
pub archive_url: Option<String>,
|
||||
pub inner: DbVal<mailpot::models::MailingList>,
|
||||
}
|
||||
|
||||
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,
|
||||
archive_url,
|
||||
},
|
||||
_,
|
||||
) = val.clone();
|
||||
|
||||
Self {
|
||||
pk,
|
||||
name,
|
||||
id,
|
||||
address,
|
||||
description,
|
||||
archive_url,
|
||||
inner: val,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
)),
|
||||
_ => 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" => Some(Value::from_serializable(&self.description)),
|
||||
"archive_url" => Some(Value::from_serializable(&self.archive_url)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn static_fields(&self) -> Option<&'static [&'static str]> {
|
||||
Some(&["pk", "name", "id", "address", "description", "archive_url"][..])
|
||||
}
|
||||
}
|
||||
|
||||
pub fn calendarize(
|
||||
_state: &minijinja::State,
|
||||
args: Value,
|
||||
hists: Value,
|
||||
) -> std::result::Result<Value, Error> {
|
||||
use chrono::Month;
|
||||
|
||||
macro_rules! month {
|
||||
($int:expr) => {{
|
||||
let int = $int;
|
||||
match int {
|
||||
1 => Month::January.name(),
|
||||
2 => Month::February.name(),
|
||||
3 => Month::March.name(),
|
||||
4 => Month::April.name(),
|
||||
5 => Month::May.name(),
|
||||
6 => Month::June.name(),
|
||||
7 => Month::July.name(),
|
||||
8 => Month::August.name(),
|
||||
9 => Month::September.name(),
|
||||
10 => Month::October.name(),
|
||||
11 => Month::November.name(),
|
||||
12 => Month::December.name(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}};
|
||||
}
|
||||
let month = args.as_str().unwrap();
|
||||
let hist = hists
|
||||
.get_item(&Value::from(month))?
|
||||
.as_seq()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| usize::try_from(v).unwrap())
|
||||
.collect::<Vec<usize>>();
|
||||
let sum: usize = hists
|
||||
.get_item(&Value::from(month))?
|
||||
.as_seq()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| usize::try_from(v).unwrap())
|
||||
.sum();
|
||||
let date = chrono::NaiveDate::parse_from_str(&format!("{}-01", month), "%F").unwrap();
|
||||
// Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
|
||||
Ok(minijinja::context! {
|
||||
month_name => month!(date.month()),
|
||||
month => month,
|
||||
month_int => date.month() as usize,
|
||||
year => date.year(),
|
||||
weeks => cal::calendarize_with_offset(date, 1),
|
||||
hist => hist,
|
||||
sum => sum,
|
||||
})
|
||||
}
|
||||
|
||||
/// `pluralize` filter for [`minijinja`].
|
||||
///
|
||||
/// Returns a plural suffix if the value is not `1`, `"1"`, or an object of
|
||||
/// length `1`. By default, the plural suffix is 's' and the singular suffix is
|
||||
/// empty (''). You can specify a singular suffix as the first argument (or
|
||||
/// `None`, for the default). You can specify a plural suffix as the second
|
||||
/// argument (or `None`, for the default).
|
||||
///
|
||||
/// See the examples for the correct usage.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// # use mailpot_web::pluralize;
|
||||
/// # use minijinja::Environment;
|
||||
///
|
||||
/// let mut env = Environment::new();
|
||||
/// env.add_filter("pluralize", pluralize);
|
||||
/// for (num, s) in [
|
||||
/// (0, "You have 0 messages."),
|
||||
/// (1, "You have 1 message."),
|
||||
/// (10, "You have 10 messages."),
|
||||
/// ] {
|
||||
/// assert_eq!(
|
||||
/// &env.render_str(
|
||||
/// "You have {{ num_messages }} message{{ num_messages|pluralize }}.",
|
||||
/// minijinja::context! {
|
||||
/// num_messages => num,
|
||||
/// }
|
||||
/// )
|
||||
/// .unwrap(),
|
||||
/// s
|
||||
/// );
|
||||
/// }
|
||||
///
|
||||
/// for (num, s) in [
|
||||
/// (0, "You have 0 walruses."),
|
||||
/// (1, "You have 1 walrus."),
|
||||
/// (10, "You have 10 walruses."),
|
||||
/// ] {
|
||||
/// assert_eq!(
|
||||
/// &env.render_str(
|
||||
/// r#"You have {{ num_walruses }} walrus{{ num_walruses|pluralize(None, "es") }}."#,
|
||||
/// minijinja::context! {
|
||||
/// num_walruses => num,
|
||||
/// }
|
||||
/// )
|
||||
/// .unwrap(),
|
||||
/// s
|
||||
/// );
|
||||
/// }
|
||||
///
|
||||
/// for (num, s) in [
|
||||
/// (0, "You have 0 cherries."),
|
||||
/// (1, "You have 1 cherry."),
|
||||
/// (10, "You have 10 cherries."),
|
||||
/// ] {
|
||||
/// assert_eq!(
|
||||
/// &env.render_str(
|
||||
/// r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
|
||||
/// minijinja::context! {
|
||||
/// num_cherries => num,
|
||||
/// }
|
||||
/// )
|
||||
/// .unwrap(),
|
||||
/// s
|
||||
/// );
|
||||
/// }
|
||||
///
|
||||
/// assert_eq!(
|
||||
/// &env.render_str(
|
||||
/// r#"You have {{ num_cherries|length }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
|
||||
/// minijinja::context! {
|
||||
/// num_cherries => vec![(); 5],
|
||||
/// }
|
||||
/// )
|
||||
/// .unwrap(),
|
||||
/// "You have 5 cherries."
|
||||
/// );
|
||||
///
|
||||
/// assert_eq!(
|
||||
/// &env.render_str(
|
||||
/// r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
|
||||
/// minijinja::context! {
|
||||
/// num_cherries => "5",
|
||||
/// }
|
||||
/// )
|
||||
/// .unwrap(),
|
||||
/// "You have 5 cherries."
|
||||
/// );
|
||||
/// assert_eq!(
|
||||
/// &env.render_str(
|
||||
/// r#"You have 1 cherr{{ num_cherries|pluralize("y", "ies") }}."#,
|
||||
/// minijinja::context! {
|
||||
/// num_cherries => true,
|
||||
/// }
|
||||
/// )
|
||||
/// .unwrap()
|
||||
/// .to_string(),
|
||||
/// "You have 1 cherry.",
|
||||
/// );
|
||||
/// assert_eq!(
|
||||
/// &env.render_str(
|
||||
/// r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
|
||||
/// minijinja::context! {
|
||||
/// num_cherries => 0.5f32,
|
||||
/// }
|
||||
/// )
|
||||
/// .unwrap_err()
|
||||
/// .to_string(),
|
||||
/// "invalid operation: Pluralize argument is not an integer, or a sequence / object with a \
|
||||
/// length but of type number (in <string>:1)",
|
||||
/// );
|
||||
/// ```
|
||||
pub fn pluralize(
|
||||
v: Value,
|
||||
singular: Option<String>,
|
||||
plural: Option<String>,
|
||||
) -> Result<Value, minijinja::Error> {
|
||||
macro_rules! int_try_from {
|
||||
($ty:ty) => {
|
||||
<$ty>::try_from(v.clone()).ok().map(|v| v != 1)
|
||||
};
|
||||
($fty:ty, $($ty:ty),*) => {
|
||||
int_try_from!($fty).or_else(|| int_try_from!($($ty),*))
|
||||
}
|
||||
}
|
||||
let is_plural: bool = v
|
||||
.as_str()
|
||||
.and_then(|s| s.parse::<i128>().ok())
|
||||
.map(|l| l != 1)
|
||||
.or_else(|| v.len().map(|l| l != 1))
|
||||
.or_else(|| int_try_from!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128, usize))
|
||||
.ok_or_else(|| {
|
||||
minijinja::Error::new(
|
||||
minijinja::ErrorKind::InvalidOperation,
|
||||
format!(
|
||||
"Pluralize argument is not an integer, or a sequence / object with a length \
|
||||
but of type {}",
|
||||
v.kind()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
Ok(match (is_plural, singular, plural) {
|
||||
(false, None, _) => "".into(),
|
||||
(false, Some(suffix), _) => suffix.into(),
|
||||
(true, _, None) => "s".into(),
|
||||
(true, _, Some(suffix)) => suffix.into(),
|
||||
})
|
||||
}
|
|
@ -349,7 +349,7 @@ pub async fn user_list_subscription_post(
|
|||
let mut db = Connection::open_db(state.conf.clone())?;
|
||||
|
||||
let Some(list) = (match id {
|
||||
ListPathIdentifier::Pk(id) => db.list(id as _)?,
|
||||
ListPathIdentifier::Pk(id) => db.list(id)?,
|
||||
ListPathIdentifier::Id(id) => db.list_by_id(id)?,
|
||||
}) else {
|
||||
return Err(ResponseError::new(
|
||||
|
|
|
@ -97,8 +97,8 @@
|
|||
<div class="entry">
|
||||
<span class="subject"><a href="{{ root_url_prefix }}{{ list_post_path(list.id, post.message_id) }}">{{ post.subject }}</a></span>
|
||||
<span class="metadata">👤 <span class="from">{{ post.address }}</span> 📆 <span class="date">{{ post.datetime }}</span></span>
|
||||
<span class="metadata">🪪 <span class="message-id">{{ post.message_id }}</span></span>
|
||||
</div>
|
||||
<span class="metadata">🪪 <span class="message-id">{{ post.message_id }}</span> ↺ <span class="replies">{{ post.replies }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -500,11 +500,10 @@ pub mod tsr {
|
|||
.unwrap_or_else(|| Cow::Owned(format!("{path}/")))
|
||||
});
|
||||
|
||||
if let Some(new_uri) = new_uri {
|
||||
Redirect::permanent(&new_uri.to_string()).into_response()
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST.into_response()
|
||||
}
|
||||
new_uri.map_or_else(
|
||||
|| StatusCode::BAD_REQUEST.into_response(),
|
||||
|new_uri| Redirect::permanent(&new_uri.to_string()).into_response(),
|
||||
)
|
||||
}
|
||||
|
||||
if let Some(path_without_trailing_slash) = path.strip_suffix('/') {
|
||||
|
@ -523,18 +522,26 @@ pub mod tsr {
|
|||
.unwrap_or_else(|| Cow::Owned(format!("{path}/")))
|
||||
});
|
||||
|
||||
if let Some(new_uri) = new_uri {
|
||||
Redirect::permanent(&new_uri.to_string()).into_response()
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST.into_response()
|
||||
}
|
||||
new_uri.map_or_else(
|
||||
|| StatusCode::BAD_REQUEST.into_response(),
|
||||
|new_uri| Redirect::permanent(&new_uri.to_string()).into_response(),
|
||||
)
|
||||
}
|
||||
|
||||
if let Some(path_without_trailing_slash) = path.strip_suffix('/') {
|
||||
(Cow::Borrowed(path_without_trailing_slash), redirect_handler)
|
||||
} else {
|
||||
(Cow::Owned(format!("{path}/")), redirect_handler)
|
||||
}
|
||||
path.strip_suffix('/').map_or_else(
|
||||
|| {
|
||||
(
|
||||
Cow::Owned(format!("{path}/")),
|
||||
redirect_handler as fn(Uri) -> Response,
|
||||
)
|
||||
},
|
||||
|path_without_trailing_slash| {
|
||||
(
|
||||
Cow::Borrowed(path_without_trailing_slash),
|
||||
redirect_handler as fn(Uri) -> Response,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
|
|
197
web/src/utils.rs
197
web/src/utils.rs
|
@ -19,188 +19,6 @@
|
|||
|
||||
use super::*;
|
||||
|
||||
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);)*
|
||||
}
|
||||
}
|
||||
add_function!(
|
||||
calendarize,
|
||||
login_path,
|
||||
logout_path,
|
||||
settings_path,
|
||||
help_path,
|
||||
list_path,
|
||||
list_settings_path,
|
||||
list_edit_path,
|
||||
list_post_path
|
||||
);
|
||||
env.set_source(Source::from_path("web/src/templates/"));
|
||||
|
||||
env
|
||||
};
|
||||
}
|
||||
|
||||
pub trait StripCarets {
|
||||
fn strip_carets(&self) -> &str;
|
||||
}
|
||||
|
||||
impl StripCarets for &str {
|
||||
fn strip_carets(&self) -> &str {
|
||||
let mut self_ref = self.trim();
|
||||
if self_ref.starts_with('<') && self_ref.ends_with('>') {
|
||||
self_ref = &self_ref[1..self_ref.len().saturating_sub(1)];
|
||||
}
|
||||
self_ref
|
||||
}
|
||||
}
|
||||
|
||||
#[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>,
|
||||
#[serde(serialize_with = "to_safe_string_opt")]
|
||||
pub archive_url: Option<String>,
|
||||
pub inner: DbVal<mailpot::models::MailingList>,
|
||||
}
|
||||
|
||||
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,
|
||||
archive_url,
|
||||
},
|
||||
_,
|
||||
) = val.clone();
|
||||
|
||||
Self {
|
||||
pk,
|
||||
name,
|
||||
id,
|
||||
address,
|
||||
description,
|
||||
archive_url,
|
||||
inner: val,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
)),
|
||||
_ => 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" => Some(Value::from_serializable(&self.description)),
|
||||
"archive_url" => Some(Value::from_serializable(&self.archive_url)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn static_fields(&self) -> Option<&'static [&'static str]> {
|
||||
Some(&["pk", "name", "id", "address", "description", "archive_url"][..])
|
||||
}
|
||||
}
|
||||
|
||||
pub fn calendarize(
|
||||
_state: &minijinja::State,
|
||||
args: Value,
|
||||
hists: Value,
|
||||
) -> std::result::Result<Value, Error> {
|
||||
use chrono::Month;
|
||||
|
||||
macro_rules! month {
|
||||
($int:expr) => {{
|
||||
let int = $int;
|
||||
match int {
|
||||
1 => Month::January.name(),
|
||||
2 => Month::February.name(),
|
||||
3 => Month::March.name(),
|
||||
4 => Month::April.name(),
|
||||
5 => Month::May.name(),
|
||||
6 => Month::June.name(),
|
||||
7 => Month::July.name(),
|
||||
8 => Month::August.name(),
|
||||
9 => Month::September.name(),
|
||||
10 => Month::October.name(),
|
||||
11 => Month::November.name(),
|
||||
12 => Month::December.name(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}};
|
||||
}
|
||||
let month = args.as_str().unwrap();
|
||||
let hist = hists
|
||||
.get_item(&Value::from(month))?
|
||||
.as_seq()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| usize::try_from(v).unwrap())
|
||||
.collect::<Vec<usize>>();
|
||||
let sum: usize = hists
|
||||
.get_item(&Value::from(month))?
|
||||
.as_seq()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| usize::try_from(v).unwrap())
|
||||
.sum();
|
||||
let date = chrono::NaiveDate::parse_from_str(&format!("{}-01", month), "%F").unwrap();
|
||||
// Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
|
||||
Ok(minijinja::context! {
|
||||
month_name => month!(date.month()),
|
||||
month => month,
|
||||
month_int => date.month() as usize,
|
||||
year => date.year(),
|
||||
weeks => cal::calendarize_with_offset(date, 1),
|
||||
hist => hist,
|
||||
sum => sum,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Crumb {
|
||||
pub label: Cow<'static, str>,
|
||||
|
@ -303,11 +121,8 @@ pub struct Next {
|
|||
impl Next {
|
||||
#[inline]
|
||||
pub fn or_else(self, cl: impl FnOnce() -> String) -> Redirect {
|
||||
if let Some(next) = self.next {
|
||||
Redirect::to(&next)
|
||||
} else {
|
||||
Redirect::to(&cl())
|
||||
}
|
||||
self.next
|
||||
.map_or_else(|| Redirect::to(&cl()), |next| Redirect::to(&next))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -328,7 +143,9 @@ where
|
|||
}
|
||||
}
|
||||
|
||||
fn to_safe_string<S>(s: impl AsRef<str>, ser: S) -> Result<S::Ok, S::Error>
|
||||
/// Serialize string to [`minijinja::value::Value`] with
|
||||
/// [`minijinja::value::Value::from_safe_string`].
|
||||
pub fn to_safe_string<S>(s: impl AsRef<str>, ser: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
|
@ -337,7 +154,9 @@ where
|
|||
Value::from_safe_string(s.to_string()).serialize(ser)
|
||||
}
|
||||
|
||||
fn to_safe_string_opt<S>(s: &Option<String>, ser: S) -> Result<S::Ok, S::Error>
|
||||
/// Serialize an optional string to [`minijinja::value::Value`] with
|
||||
/// [`minijinja::value::Value::from_safe_string`].
|
||||
pub fn to_safe_string_opt<S>(s: &Option<String>, ser: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
|
|
Loading…
Reference in New Issue