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
Manos Pitsidianakis 2023-04-16 21:15:53 +03:00
parent 4fbd009e5a
commit 26bd09d005
Signed by: Manos Pitsidianakis
GPG Key ID: 7729C7707F7E09D0
22 changed files with 1285 additions and 285 deletions

2
Cargo.lock generated
View File

@ -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",
]

View File

@ -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:

View File

@ -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"

View File

@ -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)?;

View File

@ -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(),
)?;
}
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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."];
}
}

View File

@ -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>,

View File

@ -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()

View File

@ -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(),
}
}
}

View File

@ -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

View File

@ -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

View File

@ -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
)
);
}

View File

@ -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())

View File

@ -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()

View File

@ -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::*;

View File

@ -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(),
})
}

View File

@ -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(

View File

@ -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">👤&nbsp;<span class="from">{{ post.address }}</span> 📆&nbsp;<span class="date">{{ post.datetime }}</span></span>
<span class="metadata">🪪 &nbsp;<span class="message-id">{{ post.message_id }}</span></span>
</div>
<span class="metadata">🪪 &nbsp;<span class="message-id">{{ post.message_id }}</span> &olarr;&nbsp;<span class="replies">{{ post.replies }}</span>
</div>
{% endfor %}
</div>
</div>

View File

@ -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]

View File

@ -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,
{