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",
|
"log",
|
||||||
"mailin-embedded",
|
"mailin-embedded",
|
||||||
"melib",
|
"melib",
|
||||||
|
"minijinja",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
|
@ -2120,6 +2121,7 @@ dependencies = [
|
||||||
"fallible-streaming-iterator",
|
"fallible-streaming-iterator",
|
||||||
"hashlink",
|
"hashlink",
|
||||||
"libsqlite3-sys",
|
"libsqlite3-sys",
|
||||||
|
"serde_json",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
5
Makefile
5
Makefile
|
@ -4,8 +4,9 @@ check:
|
||||||
|
|
||||||
.PHONY: fmt
|
.PHONY: fmt
|
||||||
fmt:
|
fmt:
|
||||||
cargo fmt --all
|
cargo +nightly fmt --all || cargo fmt --all
|
||||||
cargo sort -w || true
|
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
|
.PHONY: lint
|
||||||
lint:
|
lint:
|
||||||
|
|
|
@ -16,7 +16,8 @@ chrono = { version = "^0.4", features = ["serde", ] }
|
||||||
error-chain = { version = "0.12.4", default-features = false }
|
error-chain = { version = "0.12.4", default-features = false }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
melib = { version = "*", default-features = false, features = ["smtp", "unicode_algorithms", "maildir_backend"], git = "https://github.com/meli/meli", rev = "2447a2c" }
|
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 = { version = "^1", features = ["derive", ] }
|
||||||
serde_json = "^1"
|
serde_json = "^1"
|
||||||
toml = "^0.5"
|
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;
|
mod error_queue;
|
||||||
pub use error_queue::*;
|
pub use error_queue::*;
|
||||||
mod posts;
|
mod posts;
|
||||||
|
@ -57,14 +61,18 @@ pub use policies::*;
|
||||||
|
|
||||||
fn log_callback(error_code: std::ffi::c_int, message: &str) {
|
fn log_callback(error_code: std::ffi::c_int, message: &str) {
|
||||||
match error_code {
|
match error_code {
|
||||||
rusqlite::ffi::SQLITE_NOTICE => log::info!("{}", message),
|
rusqlite::ffi::SQLITE_OK
|
||||||
rusqlite::ffi::SQLITE_WARNING => log::warn!("{}", message),
|
| 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),
|
_ => 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(
|
fn user_authorizer_callback(
|
||||||
auth_context: rusqlite::hooks::AuthContext<'_>,
|
auth_context: rusqlite::hooks::AuthContext<'_>,
|
||||||
) -> rusqlite::hooks::Authorization {
|
) -> rusqlite::hooks::Authorization {
|
||||||
|
@ -129,6 +137,7 @@ impl Connection {
|
||||||
unsafe { rusqlite::trace::config_log(Some(log_callback)).unwrap() };
|
unsafe { rusqlite::trace::config_log(Some(log_callback)).unwrap() };
|
||||||
});
|
});
|
||||||
let conn = DbConnection::open(conf.db_path.to_str().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_FKEY, true)?;
|
||||||
conn.set_db_config(DbConfig::SQLITE_DBCONFIG_ENABLE_TRIGGER, true)?;
|
conn.set_db_config(DbConfig::SQLITE_DBCONFIG_ENABLE_TRIGGER, true)?;
|
||||||
conn.set_db_config(DbConfig::SQLITE_DBCONFIG_DEFENSIVE, true)?;
|
conn.set_db_config(DbConfig::SQLITE_DBCONFIG_DEFENSIVE, true)?;
|
||||||
|
|
|
@ -155,7 +155,8 @@ impl Connection {
|
||||||
let owners = self.list_owners(list.pk)?;
|
let owners = self.list_owners(list.pk)?;
|
||||||
trace!("List subscriptions {:#?}", &subscriptions);
|
trace!("List subscriptions {:#?}", &subscriptions);
|
||||||
let mut list_ctx = ListContext {
|
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_owners: &owners,
|
||||||
list: &mut list,
|
list: &mut list,
|
||||||
subscriptions: &subscriptions,
|
subscriptions: &subscriptions,
|
||||||
|
@ -246,8 +247,9 @@ impl Connection {
|
||||||
list
|
list
|
||||||
);
|
);
|
||||||
|
|
||||||
let list_policy = self.list_policy(list.pk)?;
|
let post_policy = self.list_policy(list.pk)?;
|
||||||
let approval_needed = list_policy
|
let subscription_policy = self.list_subscription_policy(list.pk)?;
|
||||||
|
let approval_needed = post_policy
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|p| p.approval_needed)
|
.map(|p| p.approval_needed)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
@ -275,7 +277,48 @@ impl Connection {
|
||||||
} else if let Err(_err) = self.add_subscription(list.pk, subscription) {
|
} else if let Err(_err) = self.add_subscription(list.pk, subscription) {
|
||||||
//FIXME: send failure notice to f
|
//FIXME: send failure notice to f
|
||||||
} else {
|
} 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."];
|
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."];
|
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."];
|
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.
|
/// The mailing list subscriptions.
|
||||||
pub subscriptions: &'list [DbVal<ListSubscription>],
|
pub subscriptions: &'list [DbVal<ListSubscription>],
|
||||||
/// The mailing list post policy.
|
/// 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
|
/// The scheduled jobs added by each filter in a list's
|
||||||
/// [`PostFilter`](message_filters::PostFilter) stack.
|
/// [`PostFilter`](message_filters::PostFilter) stack.
|
||||||
pub scheduled_jobs: Vec<MailJob>,
|
pub scheduled_jobs: Vec<MailJob>,
|
||||||
|
|
|
@ -63,7 +63,7 @@ impl PostFilter for PostRightsCheck {
|
||||||
ctx: &'p mut ListContext<'list>,
|
ctx: &'p mut ListContext<'list>,
|
||||||
) -> std::result::Result<(&'p mut Post, &'p mut ListContext<'list>), ()> {
|
) -> std::result::Result<(&'p mut Post, &'p mut ListContext<'list>), ()> {
|
||||||
trace!("Running PostRightsCheck filter");
|
trace!("Running PostRightsCheck filter");
|
||||||
if let Some(ref policy) = ctx.policy {
|
if let Some(ref policy) = ctx.post_policy {
|
||||||
if policy.announce_only {
|
if policy.announce_only {
|
||||||
trace!("post policy is announce_only");
|
trace!("post policy is announce_only");
|
||||||
let owner_addresses = ctx
|
let owner_addresses = ctx
|
||||||
|
@ -142,22 +142,33 @@ impl PostFilter for AddListHeaders {
|
||||||
) -> std::result::Result<(&'p mut Post, &'p mut ListContext<'list>), ()> {
|
) -> std::result::Result<(&'p mut Post, &'p mut ListContext<'list>), ()> {
|
||||||
trace!("Running AddListHeaders filter");
|
trace!("Running AddListHeaders filter");
|
||||||
let (mut headers, body) = melib::email::parser::mail(&post.bytes).unwrap();
|
let (mut headers, body) = melib::email::parser::mail(&post.bytes).unwrap();
|
||||||
let list_id = ctx.list.display_name();
|
|
||||||
let sender = format!("<{}>", ctx.list.address);
|
let sender = format!("<{}>", ctx.list.address);
|
||||||
headers.push((&b"List-ID"[..], list_id.as_bytes()));
|
|
||||||
headers.push((&b"Sender"[..], sender.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();
|
let list_archive = ctx.list.archive_header();
|
||||||
if let Some(post) = list_post.as_ref() {
|
|
||||||
headers.push((&b"List-Post"[..], post.as_bytes()));
|
for (hdr, val) in [
|
||||||
}
|
(b"List-Id".as_slice(), &list_id),
|
||||||
if let Some(unsubscribe) = list_unsubscribe.as_ref() {
|
(b"List-Help".as_slice(), &list_help),
|
||||||
headers.push((&b"List-Unsubscribe"[..], unsubscribe.as_bytes()));
|
(b"List-Post".as_slice(), &list_post),
|
||||||
}
|
(b"List-Unsubscribe".as_slice(), &list_unsubscribe),
|
||||||
if let Some(archive) = list_archive.as_ref() {
|
(b"List-Subscribe".as_slice(), &list_subscribe),
|
||||||
headers.push((&b"List-Archive"[..], archive.as_bytes()));
|
(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(
|
let mut new_vec = Vec::with_capacity(
|
||||||
headers
|
headers
|
||||||
.iter()
|
.iter()
|
||||||
|
|
|
@ -36,6 +36,12 @@ impl<T> DbVal<T> {
|
||||||
pub fn pk(&self) -> i64 {
|
pub fn pk(&self) -> i64 {
|
||||||
self.1
|
self.1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Unwrap inner value.
|
||||||
|
#[inline(always)]
|
||||||
|
pub fn into_inner(self) -> T {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T> std::ops::Deref for DbVal<T> {
|
impl<T> std::ops::Deref for DbVal<T> {
|
||||||
|
@ -102,22 +108,86 @@ impl MailingList {
|
||||||
format!("\"{}\" <{}>", self.name, self.address)
|
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.
|
/// Value of `List-Post` header.
|
||||||
///
|
///
|
||||||
/// See RFC2369 Section 3.4: <https://www.rfc-editor.org/rfc/rfc2369#section-3.4>
|
/// See RFC2369 Section 3.4: <https://www.rfc-editor.org/rfc/rfc2369#section-3.4>
|
||||||
pub fn post_header(&self) -> Option<String> {
|
pub fn post_header(&self, policy: Option<&PostPolicy>) -> Option<String> {
|
||||||
Some(format!("<mailto:{}>", self.address))
|
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.
|
/// Value of `List-Unsubscribe` header.
|
||||||
///
|
///
|
||||||
/// See RFC2369 Section 3.2: <https://www.rfc-editor.org/rfc/rfc2369#section-3.2>
|
/// See RFC2369 Section 3.2: <https://www.rfc-editor.org/rfc/rfc2369#section-3.2>
|
||||||
pub fn unsubscription_header(&self) -> Option<String> {
|
pub fn unsubscribe_header(&self, policy: Option<&SubscriptionPolicy>) -> Option<String> {
|
||||||
let p = self.address.split('@').collect::<Vec<&str>>();
|
policy.map_or_else(
|
||||||
Some(format!(
|
|| None,
|
||||||
"<mailto:{}+request@{}?subject=subscribe>",
|
|p| {
|
||||||
p[0], p[1]
|
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.
|
/// Value of `List-Archive` header.
|
||||||
|
@ -134,18 +204,16 @@ impl MailingList {
|
||||||
|
|
||||||
/// List unsubscribe action as a [`MailtoAddress`](super::MailtoAddress).
|
/// List unsubscribe action as a [`MailtoAddress`](super::MailtoAddress).
|
||||||
pub fn unsubscription_mailto(&self) -> MailtoAddress {
|
pub fn unsubscription_mailto(&self) -> MailtoAddress {
|
||||||
let p = self.address.split('@').collect::<Vec<&str>>();
|
|
||||||
MailtoAddress {
|
MailtoAddress {
|
||||||
address: format!("{}+request@{}", p[0], p[1]),
|
address: self.request_subaddr(),
|
||||||
subject: Some("unsubscribe".to_string()),
|
subject: Some("unsubscribe".to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List subscribe action as a [`MailtoAddress`](super::MailtoAddress).
|
/// List subscribe action as a [`MailtoAddress`](super::MailtoAddress).
|
||||||
pub fn subscription_mailto(&self) -> MailtoAddress {
|
pub fn subscription_mailto(&self) -> MailtoAddress {
|
||||||
let p = self.address.split('@').collect::<Vec<&str>>();
|
|
||||||
MailtoAddress {
|
MailtoAddress {
|
||||||
address: format!("{}+request@{}", p[0], p[1]),
|
address: self.request_subaddr(),
|
||||||
subject: Some("subscribe".to_string()),
|
subject: Some("subscribe".to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -379,3 +447,114 @@ impl std::fmt::Display for Account {
|
||||||
write!(fmt, "{:?}", self)
|
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 (
|
CREATE TABLE IF NOT EXISTS templates (
|
||||||
pk INTEGER PRIMARY KEY NOT NULL,
|
pk INTEGER PRIMARY KEY NOT NULL,
|
||||||
list INTEGER UNIQUE,
|
name TEXT NOT NULL,
|
||||||
|
list INTEGER,
|
||||||
subject TEXT,
|
subject TEXT,
|
||||||
|
headers_json TEXT,
|
||||||
body TEXT NOT NULL,
|
body TEXT NOT NULL,
|
||||||
created INTEGER NOT NULL DEFAULT (unixepoch()),
|
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
|
-- # Queues
|
||||||
|
|
|
@ -124,11 +124,15 @@ CREATE TABLE IF NOT EXISTS post (
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS templates (
|
CREATE TABLE IF NOT EXISTS templates (
|
||||||
pk INTEGER PRIMARY KEY NOT NULL,
|
pk INTEGER PRIMARY KEY NOT NULL,
|
||||||
list INTEGER UNIQUE,
|
name TEXT NOT NULL,
|
||||||
|
list INTEGER,
|
||||||
subject TEXT,
|
subject TEXT,
|
||||||
|
headers_json TEXT,
|
||||||
body TEXT NOT NULL,
|
body TEXT NOT NULL,
|
||||||
created INTEGER NOT NULL DEFAULT (unixepoch()),
|
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
|
-- # 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 TOKEN_KEY: &str = "ssh_challenge";
|
||||||
const EXPIRY_IN_SECS: i64 = 6 * 60;
|
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 {
|
pub enum Role {
|
||||||
User,
|
User,
|
||||||
Admin,
|
Admin,
|
||||||
|
@ -114,17 +114,18 @@ pub async fn ssh_signin(
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let (token, timestamp): (String, i64) = if let Some(tok) = prev_token {
|
let (token, timestamp): (String, i64) = prev_token.map_or_else(
|
||||||
tok
|
|| {
|
||||||
} else {
|
use rand::{distributions::Alphanumeric, thread_rng, Rng};
|
||||||
use rand::{distributions::Alphanumeric, thread_rng, Rng};
|
|
||||||
|
|
||||||
let mut rng = thread_rng();
|
let mut rng = thread_rng();
|
||||||
let chars: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect();
|
let chars: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect();
|
||||||
println!("Random chars: {}", chars);
|
println!("Random chars: {}", chars);
|
||||||
session.insert(TOKEN_KEY, (&chars, now)).unwrap();
|
session.insert(TOKEN_KEY, (&chars, now)).unwrap();
|
||||||
(chars, now)
|
(chars, now)
|
||||||
};
|
},
|
||||||
|
|tok| tok,
|
||||||
|
);
|
||||||
let timeout_left = ((timestamp + EXPIRY_IN_SECS) - now) as f64 / 60.0;
|
let timeout_left = ((timestamp + EXPIRY_IN_SECS) - now) as f64 / 60.0;
|
||||||
|
|
||||||
let root_url_prefix = &state.root_url_prefix;
|
let root_url_prefix = &state.root_url_prefix;
|
||||||
|
@ -190,11 +191,7 @@ pub async fn ssh_signin_post(
|
||||||
"{}{}{}",
|
"{}{}{}",
|
||||||
state.root_url_prefix,
|
state.root_url_prefix,
|
||||||
LoginPath.to_uri(),
|
LoginPath.to_uri(),
|
||||||
if let Some(ref next) = next.next {
|
next.next.as_ref().map_or("", |next| next.as_str())
|
||||||
next.as_str()
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
)));
|
)));
|
||||||
} else {
|
} else {
|
||||||
tok
|
tok
|
||||||
|
@ -208,11 +205,7 @@ pub async fn ssh_signin_post(
|
||||||
"{}{}{}",
|
"{}{}{}",
|
||||||
state.root_url_prefix,
|
state.root_url_prefix,
|
||||||
LoginPath.to_uri(),
|
LoginPath.to_uri(),
|
||||||
if let Some(ref next) = next.next {
|
next.next.as_ref().map_or("", |next| next.as_str())
|
||||||
next.as_str()
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
)));
|
)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -422,11 +415,8 @@ pub mod auth_request {
|
||||||
T: RangeBounds<Role> + Clone + Send + Sync,
|
T: RangeBounds<Role> + Clone + Send + Sync,
|
||||||
{
|
{
|
||||||
fn contains(&self, role: Option<Role>) -> bool {
|
fn contains(&self, role: Option<Role>) -> bool {
|
||||||
if let Some(role) = role {
|
role.as_ref()
|
||||||
RangeBounds::contains(self, &role)
|
.map_or_else(|| role.is_none(), |role| RangeBounds::contains(self, role))
|
||||||
} else {
|
|
||||||
role.is_none()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -483,8 +473,9 @@ pub mod auth_request {
|
||||||
|
|
||||||
_ => {
|
_ => {
|
||||||
let unauthorized_response = if let Some(ref login_url) = self.login_url {
|
let unauthorized_response = if let Some(ref login_url) = self.login_url {
|
||||||
let url: Cow<'static, str> =
|
let url: Cow<'static, str> = self.redirect_field_name.as_ref().map_or_else(
|
||||||
if let Some(ref next) = self.redirect_field_name {
|
|| login_url.as_ref().clone(),
|
||||||
|
|next| {
|
||||||
format!(
|
format!(
|
||||||
"{login_url}?{next}={}",
|
"{login_url}?{next}={}",
|
||||||
percent_encoding::utf8_percent_encode(
|
percent_encoding::utf8_percent_encode(
|
||||||
|
@ -493,9 +484,9 @@ pub mod auth_request {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.into()
|
.into()
|
||||||
} else {
|
},
|
||||||
login_url.as_ref().clone()
|
);
|
||||||
};
|
|
||||||
Response::builder()
|
Response::builder()
|
||||||
.status(http::StatusCode::TEMPORARY_REDIRECT)
|
.status(http::StatusCode::TEMPORARY_REDIRECT)
|
||||||
.header(http::header::LOCATION, url.as_ref())
|
.header(http::header::LOCATION, url.as_ref())
|
||||||
|
|
|
@ -79,12 +79,11 @@ pub fn calendarize_with_offset(date: NaiveDate, offset: u32) -> Vec<[u32; 7]> {
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.weekday()
|
.weekday()
|
||||||
.num_days_from_sunday();
|
.num_days_from_sunday();
|
||||||
let mut first_date_day;
|
let mut first_date_day = if num_days_from_sunday < offset {
|
||||||
if num_days_from_sunday < offset {
|
num_days_from_sunday + (7 - offset)
|
||||||
first_date_day = num_days_from_sunday + (7 - offset);
|
|
||||||
} else {
|
} 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)
|
let end_date = NaiveDate::from_ymd_opt(year, month + 1, 1)
|
||||||
.unwrap_or_else(|| NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap())
|
.unwrap_or_else(|| NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap())
|
||||||
.pred_opt()
|
.pred_opt()
|
||||||
|
|
|
@ -17,6 +17,35 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* 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::{
|
pub use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
handler::Handler,
|
handler::Handler,
|
||||||
|
@ -54,12 +83,14 @@ use tokio::sync::RwLock;
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod cal;
|
pub mod cal;
|
||||||
|
pub mod minijinja_utils;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod typed_paths;
|
pub mod typed_paths;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|
||||||
pub use auth::*;
|
pub use auth::*;
|
||||||
pub use cal::{calendarize, *};
|
pub use cal::{calendarize, *};
|
||||||
|
pub use minijinja_utils::*;
|
||||||
pub use settings::*;
|
pub use settings::*;
|
||||||
pub use typed_paths::{tsr::RouterExt, *};
|
pub use typed_paths::{tsr::RouterExt, *};
|
||||||
pub use utils::*;
|
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 mut db = Connection::open_db(state.conf.clone())?;
|
||||||
|
|
||||||
let Some(list) = (match id {
|
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)?,
|
ListPathIdentifier::Id(id) => db.list_by_id(id)?,
|
||||||
}) else {
|
}) else {
|
||||||
return Err(ResponseError::new(
|
return Err(ResponseError::new(
|
||||||
|
|
|
@ -97,8 +97,8 @@
|
||||||
<div class="entry">
|
<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="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="from">{{ post.address }}</span> 📆 <span class="date">{{ post.datetime }}</span></span>
|
||||||
<span class="metadata">🪪 <span class="message-id">{{ post.message_id }}</span></span>
|
<span class="metadata">🪪 <span class="message-id">{{ post.message_id }}</span> ↺ <span class="replies">{{ post.replies }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -500,11 +500,10 @@ pub mod tsr {
|
||||||
.unwrap_or_else(|| Cow::Owned(format!("{path}/")))
|
.unwrap_or_else(|| Cow::Owned(format!("{path}/")))
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(new_uri) = new_uri {
|
new_uri.map_or_else(
|
||||||
Redirect::permanent(&new_uri.to_string()).into_response()
|
|| StatusCode::BAD_REQUEST.into_response(),
|
||||||
} else {
|
|new_uri| Redirect::permanent(&new_uri.to_string()).into_response(),
|
||||||
StatusCode::BAD_REQUEST.into_response()
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(path_without_trailing_slash) = path.strip_suffix('/') {
|
if let Some(path_without_trailing_slash) = path.strip_suffix('/') {
|
||||||
|
@ -523,18 +522,26 @@ pub mod tsr {
|
||||||
.unwrap_or_else(|| Cow::Owned(format!("{path}/")))
|
.unwrap_or_else(|| Cow::Owned(format!("{path}/")))
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(new_uri) = new_uri {
|
new_uri.map_or_else(
|
||||||
Redirect::permanent(&new_uri.to_string()).into_response()
|
|| StatusCode::BAD_REQUEST.into_response(),
|
||||||
} else {
|
|new_uri| Redirect::permanent(&new_uri.to_string()).into_response(),
|
||||||
StatusCode::BAD_REQUEST.into_response()
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(path_without_trailing_slash) = path.strip_suffix('/') {
|
path.strip_suffix('/').map_or_else(
|
||||||
(Cow::Borrowed(path_without_trailing_slash), redirect_handler)
|
|| {
|
||||||
} else {
|
(
|
||||||
(Cow::Owned(format!("{path}/")), redirect_handler)
|
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]
|
#[inline]
|
||||||
|
|
197
web/src/utils.rs
197
web/src/utils.rs
|
@ -19,188 +19,6 @@
|
||||||
|
|
||||||
use super::*;
|
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)]
|
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)]
|
||||||
pub struct Crumb {
|
pub struct Crumb {
|
||||||
pub label: Cow<'static, str>,
|
pub label: Cow<'static, str>,
|
||||||
|
@ -303,11 +121,8 @@ pub struct Next {
|
||||||
impl Next {
|
impl Next {
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn or_else(self, cl: impl FnOnce() -> String) -> Redirect {
|
pub fn or_else(self, cl: impl FnOnce() -> String) -> Redirect {
|
||||||
if let Some(next) = self.next {
|
self.next
|
||||||
Redirect::to(&next)
|
.map_or_else(|| Redirect::to(&cl()), |next| Redirect::to(&next))
|
||||||
} else {
|
|
||||||
Redirect::to(&cl())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
where
|
||||||
S: serde::Serializer,
|
S: serde::Serializer,
|
||||||
{
|
{
|
||||||
|
@ -337,7 +154,9 @@ where
|
||||||
Value::from_safe_string(s.to_string()).serialize(ser)
|
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
|
where
|
||||||
S: serde::Serializer,
|
S: serde::Serializer,
|
||||||
{
|
{
|
||||||
|
|
Loading…
Reference in New Issue