Compare commits

...

7 Commits
main ... axum

Author SHA1 Message Date
Manos Pitsidianakis 37a269a2d0
Rename mpot-web crate to mailpot-web 2023-04-14 15:54:34 +03:00
Manos Pitsidianakis 2283b6b61e
web: add page titles 2023-04-14 15:52:58 +03:00
Manos Pitsidianakis 3da43ed984
web: add account settings editing 2023-04-14 15:52:58 +03:00
Manos Pitsidianakis 6dd3575355
Rename memberships to subscriptions 2023-04-14 15:51:41 +03:00
Manos Pitsidianakis d11df45c9d
cli: add account{list,add,edit,remove,info} commands 2023-04-14 15:51:41 +03:00
Manos Pitsidianakis 9815a6f0b1
web: add messages list in session
Add messages list for showing notifications (success, error, warning,
info) to users after actions like saving and/or submitting forms.
2023-04-14 15:51:41 +03:00
Manos Pitsidianakis d6273b416e
Add web server with axium and SSH OTP auth
web: impl auth with ssh OTP
2023-04-14 15:51:34 +03:00
47 changed files with 4751 additions and 769 deletions

721
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -4,4 +4,5 @@ members = [
"cli",
"core",
"rest-http",
"web",
]

View File

@ -130,8 +130,8 @@ TRACE - Received envelope to post: Envelope {
TRACE - Is post related to list [#1 test] Test list <test@localhost>? false
TRACE - Is post related to list [#2 test-announce] test announcements <test-announce@localhost>? true
TRACE - Examining list "test announcements" <test-announce@localhost>
TRACE - List members [
ListMembership {
TRACE - List subscriptions [
ListSubscription {
list: 2,
address: "exxxxx@localhost",
name: None,
@ -147,9 +147,9 @@ TRACE - Running FixCRLF filter
TRACE - Running PostRightsCheck filter
TRACE - Running AddListHeaders filter
TRACE - Running FinalizeRecipients filter
TRACE - examining member ListMembership { list: 2, address: "exxxxx@localhost", name: None, digest: false, hide_address: false, receive_duplicates: false, receive_own_posts: true, receive_confirmation: true, enabled: true }
TRACE - member is submitter
TRACE - Member gets copy
TRACE - examining subscription ListSubscription { list: 2, address: "exxxxx@localhost", name: None, digest: false, hide_address: false, receive_duplicates: false, receive_own_posts: true, receive_confirmation: true, enabled: true }
TRACE - subscription is submitter
TRACE - subscription gets copy
TRACE - result Ok(
Post {
list: MailingList {
@ -164,7 +164,7 @@ TRACE - result Ok(
display_name: "Mxxxx Pxxxxxxxxxxxx",
address_spec: "exxxxx@localhost",
},
members: 1,
subscriptions: 1,
bytes: 851,
policy: None,
to: [
@ -217,17 +217,17 @@ db.set_list_policy(
pk: 0,
list: list_pk,
announce_only: false,
subscriber_only: true,
subscription_only: true,
approval_needed: false,
open: false,
custom: false,
},
)?;
// Drop privileges; we can only process new e-mail and modify memberships from now on.
// Drop privileges; we can only process new e-mail and modify subscriptions from now on.
let mut db = db.untrusted();
assert_eq!(db.list_members(list_pk)?.len(), 0);
assert_eq!(db.list_subscriptions(list_pk)?.len(), 0);
assert_eq!(db.list_posts(list_pk, None)?.len(), 0);
// Process a subscription request e-mail
@ -241,7 +241,7 @@ Message-ID: <1@example.com>
let envelope = melib::Envelope::from_bytes(subscribe_bytes, None)?;
db.post(&envelope, subscribe_bytes, /* dry_run */ false)?;
assert_eq!(db.list_members(list_pk)?.len(), 1);
assert_eq!(db.list_subscriptions(list_pk)?.len(), 1);
assert_eq!(db.list_posts(list_pk, None)?.len(), 0);
// Process a post
@ -257,7 +257,7 @@ let envelope =
melib::Envelope::from_bytes(post_bytes, None).expect("Could not parse message");
db.post(&envelope, post_bytes, /* dry_run */ false)?;
assert_eq!(db.list_members(list_pk)?.len(), 1);
assert_eq!(db.list_subscriptions(list_pk)?.len(), 1);
assert_eq!(db.list_posts(list_pk, None)?.len(), 1);
# Ok::<(), Error>(())
```

View File

@ -7,31 +7,31 @@
{% else %}
{% if not post_policy.no_subscriptions %}
<h2 id="subscribe">Subscribe</h2>
{% set subscribe_mailto=list.subscribe_mailto() %}
{% if subscribe_mailto %}
{% if subscribe_mailto.subject %}
{% set subscription_mailto=list.subscription_mailto() %}
{% if subscription_mailto %}
{% if subscription_mailto.subject %}
<p>
<a href="mailto:{{ subscribe_mailto.address|safe }}?subject={{ subscribe_mailto.subject|safe }}"><code>{{ subscribe_mailto.address }}</code></a> with the following subject: <code>{{ subscribe_mailto.subject}}</code>
<a href="mailto:{{ subscription_mailto.address|safe }}?subject={{ subscription_mailto.subject|safe }}"><code>{{ subscription_mailto.address }}</code></a> with the following subject: <code>{{ subscription_mailto.subject}}</code>
</p>
{% else %}
<p>
<a href="mailto:{{ subscribe_mailto.address|safe }}"><code>{{ subscribe_mailto.address }}</code></a>
<a href="mailto:{{ subscription_mailto.address|safe }}"><code>{{ subscription_mailto.address }}</code></a>
</p>
{% endif %}
{% else %}
<p>List is not open for subscriptions.</p>
{% endif %}
{% set unsubscribe_mailto=list.unsubscribe_mailto() %}
{% if unsubscribe_mailto %}
{% set unsubscription_mailto=list.unsubscription_mailto() %}
{% if unsubscription_mailto %}
<h2 id="unsubscribe">Unsubscribe</h2>
{% if unsubscribe_mailto.subject %}
{% if unsubscription_mailto.subject %}
<p>
<a href="mailto:{{ unsubscribe_mailto.address|safe }}?subject={{ unsubscribe_mailto.subject|safe }}"><code>{{ unsubscribe_mailto.address }}</code></a> with the following subject: <code>{{unsubscribe_mailto.subject}}</code>
<a href="mailto:{{ unsubscription_mailto.address|safe }}?subject={{ unsubscription_mailto.subject|safe }}"><code>{{ unsubscription_mailto.address }}</code></a> with the following subject: <code>{{unsubscription_mailto.subject}}</code>
</p>
{% else %}
<p>
<a href="mailto:{{ unsubscribe_mailto.address|safe }}"><code>{{ unsubscribe_mailto.address }}</code></a>
<a href="mailto:{{ unsubscription_mailto.address|safe }}"><code>{{ unsubscription_mailto.address }}</code></a>
</p>
{% endif %}
{% endif %}
@ -40,8 +40,8 @@
<h2 id="post">Post</h2>
{% if post_policy.announce_only %}
<p>List is <em>announce-only</em>, i.e. you can only subscribe to receive announcements.</p>
{% elif post_policy.subscriber_only %}
<p>List is <em>subscriber-only</em>, i.e. you can only post if you are subscribed.</p>
{% elif post_policy.subscription_only %}
<p>List is <em>subscription-only</em>, i.e. you can only post if you are subscribed.</p>
<p>If you are subscribed, you can send new posts to:
<a href="mailto:{{ list.address| safe }}"><code>{{ list.address }}</code></a>
</p>

View File

@ -98,8 +98,12 @@ impl Object for MailingList {
_args: &[Value],
) -> std::result::Result<Value, Error> {
match name {
"subscribe_mailto" => Ok(Value::from_serializable(&self.inner.subscribe_mailto())),
"unsubscribe_mailto" => Ok(Value::from_serializable(&self.inner.unsubscribe_mailto())),
"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!("aaaobject has no method named {name}"),

View File

@ -133,6 +133,54 @@ pub enum Command {
#[clap(flatten)]
config: PostfixConfig,
},
/// All Accounts.
Accounts,
/// Account info.
AccountInfo {
/// Account address.
address: String,
},
/// Add account.
AddAccount {
/// E-mail address.
#[arg(long)]
address: String,
/// SSH public key for authentication.
#[arg(long)]
password: String,
/// Name.
#[arg(long)]
name: Option<String>,
/// Public key.
#[arg(long)]
public_key: Option<String>,
#[arg(long)]
/// Is account enabled.
enabled: Option<bool>,
},
/// Remove account.
RemoveAccount {
#[arg(long)]
/// E-mail address.
address: String,
},
/// Update account info.
UpdateAccount {
/// Address to edit.
address: String,
/// Public key for authentication.
#[arg(long)]
password: Option<String>,
/// Name.
#[arg(long)]
name: Option<Option<String>>,
/// Public key.
#[arg(long)]
public_key: Option<Option<String>>,
#[arg(long)]
/// Is account enabled.
enabled: Option<Option<bool>>,
},
}
/// Postfix config values.
@ -193,9 +241,9 @@ pub enum ErrorQueueCommand {
},
}
/// Member options.
/// Subscription options.
#[derive(Debug, Args)]
pub struct MemberOptions {
pub struct SubscriptionOptions {
/// Name.
#[arg(long)]
pub name: Option<String>,
@ -223,30 +271,44 @@ pub struct MemberOptions {
pub enabled: Option<bool>,
}
/// Account options.
#[derive(Debug, Args)]
pub struct AccountOptions {
/// Name.
#[arg(long)]
pub name: Option<String>,
/// Public key.
#[arg(long)]
pub public_key: Option<String>,
#[arg(long)]
/// Is account enabled.
pub enabled: Option<bool>,
}
#[derive(Debug, Subcommand)]
pub enum ListCommand {
/// List members of list.
Members,
/// Add member to list.
AddMember {
/// List subscriptions of list.
Subscriptions,
/// Add subscription to list.
AddSubscription {
/// E-mail address.
#[arg(long)]
address: String,
#[clap(flatten)]
member_options: MemberOptions,
subscription_options: SubscriptionOptions,
},
/// Remove member from list.
RemoveMember {
/// Remove subscription from list.
RemoveSubscription {
#[arg(long)]
/// E-mail address.
address: String,
},
/// Update membership info.
UpdateMembership {
/// Update subscription info.
UpdateSubscription {
/// Address to edit.
address: String,
#[clap(flatten)]
member_options: MemberOptions,
subscription_options: SubscriptionOptions,
},
/// Add a new post policy.
AddPolicy {
@ -254,10 +316,10 @@ pub enum ListCommand {
/// Only list owners can post.
announce_only: bool,
#[arg(long)]
/// Only subscribers can post.
subscriber_only: bool,
/// Only subscriptions can post.
subscription_only: bool,
#[arg(long)]
/// Subscribers can post.
/// Subscriptions can post.
/// Other posts must be approved by list owners.
approval_needed: bool,
#[arg(long)]
@ -282,7 +344,7 @@ pub enum ListCommand {
/// Anyone can subscribe without restrictions.
open: bool,
#[arg(long)]
/// Only list owners can manually add subscribers.
/// Only list owners can manually add subscriptions.
manual: bool,
#[arg(long)]
/// Anyone can request to subscribe.
@ -308,14 +370,14 @@ pub enum ListCommand {
/// List owner primary key.
pk: i64,
},
/// Alias for update-membership --enabled true.
EnableMembership {
/// Member address.
/// Alias for update-subscription --enabled true.
EnableSubscription {
/// Subscription address.
address: String,
},
/// Alias for update-membership --enabled false.
DisableMembership {
/// Member address.
/// Alias for update-subscription --enabled false.
DisableSubscription {
/// Subscription address.
address: String,
},
/// Update mailing list details.
@ -345,7 +407,7 @@ pub enum ListCommand {
request_local_part: Option<String>,
/// Require verification of e-mails for new subscriptions.
///
/// Subscriptions that are initiated from the member's address are verified automatically.
/// Subscriptions that are initiated from the subscription's address are verified automatically.
#[arg(long)]
verify: Option<bool>,
/// Public visibility of list.

View File

@ -40,6 +40,17 @@ macro_rules! list {
})
}};
}
macro_rules! string_opts {
($field:ident) => {
if $field.as_deref().map(str::is_empty).unwrap_or(false) {
None
} else {
Some($field)
}
};
}
fn run_app(opt: Opt) -> Result<()> {
if opt.debug {
println!("DEBUG: {:?}", &opt);
@ -58,7 +69,7 @@ fn run_app(opt: Opt) -> Result<()> {
let mut stdout = std::io::stdout();
serde_json::to_writer_pretty(&mut stdout, &lists)?;
for l in &lists {
serde_json::to_writer_pretty(&mut stdout, &db.list_members(l.pk)?)?;
serde_json::to_writer_pretty(&mut stdout, &db.list_subscriptions(l.pk)?)?;
}
}
ListLists => {
@ -95,21 +106,21 @@ fn run_app(opt: Opt) -> Result<()> {
};
use ListCommand::*;
match cmd {
Members => {
let members = db.list_members(list.pk)?;
if members.is_empty() {
println!("No members found.");
Subscriptions => {
let subscriptions = db.list_subscriptions(list.pk)?;
if subscriptions.is_empty() {
println!("No subscriptions found.");
} else {
println!("Members of list {}", list.id);
for l in members {
println!("Subscriptions of list {}", list.id);
for l in subscriptions {
println!("- {}", &l);
}
}
}
AddMember {
AddSubscription {
address,
member_options:
MemberOptions {
subscription_options:
SubscriptionOptions {
name,
digest,
hide_address,
@ -120,13 +131,14 @@ fn run_app(opt: Opt) -> Result<()> {
verified,
},
} => {
db.add_member(
db.add_subscription(
list.pk,
ListMembership {
ListSubscription {
pk: 0,
list: list.pk,
name,
address,
account: None,
name,
digest: digest.unwrap_or(false),
hide_address: hide_address.unwrap_or(false),
receive_confirmation: receive_confirmation.unwrap_or(true),
@ -137,13 +149,14 @@ fn run_app(opt: Opt) -> Result<()> {
},
)?;
}
RemoveMember { address } => {
RemoveSubscription { address } => {
let mut input = String::new();
loop {
println!(
"Are you sure you want to remove membership of {} from list {}? [Yy/n]",
"Are you sure you want to remove subscription of {} from list {}? [Yy/n]",
address, list
);
let mut input = String::new();
input.clear();
std::io::stdin().read_line(&mut input)?;
if input.trim() == "Y" || input.trim() == "y" || input.trim() == "" {
break;
@ -152,12 +165,13 @@ fn run_app(opt: Opt) -> Result<()> {
}
}
db.remove_membership(list.pk, &address)?;
db.remove_subscription(list.pk, &address)?;
}
Health => {
println!("{} health:", list);
let list_owners = db.list_owners(list.pk)?;
let list_policy = db.list_policy(list.pk)?;
let post_policy = db.list_policy(list.pk)?;
let subscription_policy = db.list_subscription_policy(list.pk)?;
if list_owners.is_empty() {
println!("\tList has no owners: you should add at least one.");
} else {
@ -165,23 +179,29 @@ fn run_app(opt: Opt) -> Result<()> {
println!("\tList owner: {}.", owner);
}
}
if let Some(list_policy) = list_policy {
println!("\tList has post policy: {}.", list_policy);
if let Some(p) = post_policy {
println!("\tList has post policy: {p}.");
} else {
println!("\tList has no post policy: you should add one.");
}
if let Some(p) = subscription_policy {
println!("\tList has subscription policy: {p}.");
} else {
println!("\tList has no subscription policy: you should add one.");
}
}
Info => {
println!("{} info:", list);
let list_owners = db.list_owners(list.pk)?;
let list_policy = db.list_policy(list.pk)?;
let members = db.list_members(list.pk)?;
if members.is_empty() {
println!("No members.");
} else if members.len() == 1 {
println!("1 member.");
let post_policy = db.list_policy(list.pk)?;
let subscription_policy = db.list_subscription_policy(list.pk)?;
let subscriptions = db.list_subscriptions(list.pk)?;
if subscriptions.is_empty() {
println!("No subscriptions.");
} else if subscriptions.len() == 1 {
println!("1 subscription.");
} else {
println!("{} members.", members.len());
println!("{} subscriptions.", subscriptions.len());
}
if list_owners.is_empty() {
println!("List owners: None");
@ -191,16 +211,21 @@ fn run_app(opt: Opt) -> Result<()> {
println!("\t- {}", o);
}
}
if let Some(s) = list_policy {
println!("List policy: {}", s);
if let Some(s) = post_policy {
println!("Post policy: {s}");
} else {
println!("List policy: None");
println!("Post policy: None");
}
if let Some(s) = subscription_policy {
println!("Subscription policy: {s}");
} else {
println!("Subscription policy: None");
}
}
UpdateMembership {
UpdateSubscription {
address,
member_options:
MemberOptions {
subscription_options:
SubscriptionOptions {
name,
digest,
hide_address,
@ -220,9 +245,10 @@ fn run_app(opt: Opt) -> Result<()> {
} else {
Some(name)
};
let changeset = ListMembershipChangeset {
let changeset = ListSubscriptionChangeset {
list: list.pk,
address,
account: None,
name,
digest,
verified,
@ -232,11 +258,11 @@ fn run_app(opt: Opt) -> Result<()> {
receive_confirmation,
enabled,
};
db.update_member(changeset)?;
db.update_subscription(changeset)?;
}
AddPolicy {
announce_only,
subscriber_only,
subscription_only,
approval_needed,
open,
custom,
@ -245,7 +271,7 @@ fn run_app(opt: Opt) -> Result<()> {
pk: 0,
list: list.pk,
announce_only,
subscriber_only,
subscription_only,
approval_needed,
open,
custom,
@ -264,7 +290,7 @@ fn run_app(opt: Opt) -> Result<()> {
request,
custom,
} => {
let policy = SubscribePolicy {
let policy = SubscriptionPolicy {
pk: 0,
list: list.pk,
send_confirmation,
@ -273,11 +299,11 @@ fn run_app(opt: Opt) -> Result<()> {
request,
custom,
};
let new_val = db.set_list_subscribe_policy(policy)?;
let new_val = db.set_list_subscription_policy(policy)?;
println!("Added new subscribe policy with pk = {}", new_val.pk());
}
RemoveSubscribePolicy { pk } => {
db.remove_list_subscribe_policy(list.pk, pk)?;
db.remove_list_subscription_policy(list.pk, pk)?;
println!("Removed subscribe policy with pk = {}", pk);
}
AddListOwner { address, name } => {
@ -294,10 +320,11 @@ fn run_app(opt: Opt) -> Result<()> {
db.remove_list_owner(list.pk, pk)?;
println!("Removed list owner with pk = {}", pk);
}
EnableMembership { address } => {
let changeset = ListMembershipChangeset {
EnableSubscription { address } => {
let changeset = ListSubscriptionChangeset {
list: list.pk,
address,
account: None,
name: None,
digest: None,
verified: None,
@ -307,12 +334,13 @@ fn run_app(opt: Opt) -> Result<()> {
receive_own_posts: None,
receive_confirmation: None,
};
db.update_member(changeset)?;
db.update_subscription(changeset)?;
}
DisableMembership { address } => {
let changeset = ListMembershipChangeset {
DisableSubscription { address } => {
let changeset = ListSubscriptionChangeset {
list: list.pk,
address,
account: None,
name: None,
digest: None,
enabled: Some(false),
@ -322,7 +350,7 @@ fn run_app(opt: Opt) -> Result<()> {
receive_own_posts: None,
receive_confirmation: None,
};
db.update_member(changeset)?;
db.update_subscription(changeset)?;
}
Update {
name,
@ -336,19 +364,6 @@ fn run_app(opt: Opt) -> Result<()> {
hidden,
enabled,
} => {
macro_rules! string_opts {
($field:ident) => {
if $field
.as_ref()
.map(|s: &String| s.is_empty())
.unwrap_or(false)
{
None
} else {
Some($field)
}
};
}
let description = string_opts!(description);
let archive_url = string_opts!(archive_url);
let owner_local_part = string_opts!(owner_local_part);
@ -575,6 +590,81 @@ fn run_app(opt: Opt) -> Result<()> {
println!("{maps}\n\n{mastercf}\n");
}
Accounts => {
let accounts = db.accounts()?;
if accounts.is_empty() {
println!("No accounts found.");
} else {
for a in accounts {
println!("- {:?}", a);
}
}
}
AccountInfo { address } => {
if let Some(acc) = db.account_by_address(&address)? {
let subs = db.account_subscriptions(acc.pk())?;
if subs.is_empty() {
println!("No subscriptions found.");
} else {
for s in subs {
let list = db.list(s.list).unwrap_or_else(|err| panic!("Found subscription with list_pk = {} but no such list exists.\nListSubscription = {:?}\n\n{err}", s.list, s));
println!("- {:?} {}", s, list);
}
}
} else {
println!("account with this address not found!");
};
}
AddAccount {
address,
password,
name,
public_key,
enabled,
} => {
db.add_account(Account {
pk: 0,
name,
address,
public_key,
password,
enabled: enabled.unwrap_or(true),
})?;
}
RemoveAccount { address } => {
let mut input = String::new();
loop {
println!(
"Are you sure you want to remove account with address {}? [Yy/n]",
address
);
input.clear();
std::io::stdin().read_line(&mut input)?;
if input.trim() == "Y" || input.trim() == "y" || input.trim() == "" {
break;
} else if input.trim() == "n" {
return Ok(());
}
}
db.remove_account(&address)?;
}
UpdateAccount {
address,
password,
name,
public_key,
enabled,
} => {
let changeset = AccountChangeset {
address,
name,
public_key,
password,
enabled,
};
db.update_account(changeset)?;
}
}
Ok(())

View File

@ -36,12 +36,20 @@ pub struct Connection {
conf: Configuration,
}
impl std::fmt::Debug for Connection {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
fmt.debug_struct("Connection")
.field("conf", &self.conf)
.finish()
}
}
mod error_queue;
pub use error_queue::*;
mod posts;
pub use posts::*;
mod members;
pub use members::*;
mod subscriptions;
pub use subscriptions::*;
mod policies;
pub use policies::*;
@ -52,7 +60,7 @@ fn log_callback(error_code: std::ffi::c_int, message: &str) {
_ => 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 {
@ -61,15 +69,31 @@ fn user_authorizer_callback(
// [ref:sync_auth_doc] sync with `untrusted()` rustdoc when changing this.
match auth_context.action {
AuthAction::Delete {
table_name: "queue" | "candidate_membership" | "membership",
table_name: "queue" | "candidate_subscription" | "subscription",
}
| AuthAction::Insert {
table_name: "post" | "queue" | "candidate_membership" | "membership",
table_name: "post" | "queue" | "candidate_subscription" | "subscription",
}
| AuthAction::Update {
table_name: "candidate_membership" | "membership" | "account" | "templates",
table_name: "candidate_subscription" | "templates",
column_name: "accepted" | "last_modified" | "verified" | "address",
}
| AuthAction::Update {
table_name: "account",
column_name: "last_modified" | "name" | "public_key" | "password",
}
| AuthAction::Update {
table_name: "subscription",
column_name:
"last_modified"
| "account"
| "digest"
| "verified"
| "hide_address"
| "receive_duplicates"
| "receive_own_posts"
| "receive_confirmation",
}
| AuthAction::Select
| AuthAction::Savepoint { .. }
| AuthAction::Transaction { .. }
@ -126,7 +150,8 @@ impl Connection {
// [tag:sync_auth_doc]
/// Sets operational limits for this connection.
///
/// - Allow `INSERT`, `DELETE` only for "queue", "candidate_membership", "membership".
/// - Allow `INSERT`, `DELETE` only for "queue", "candidate_subscription", "subscription".
/// - Allow `UPDATE` only for "subscription" user facing settings.
/// - Allow `INSERT` only for "post".
/// - Allow read access to all tables.
/// - Allow `SELECT`, `TRANSACTION`, `SAVEPOINT`, and the `strftime` function.

View File

@ -1,309 +0,0 @@
/*
* 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/>.
*/
use super::*;
impl Connection {
/// Fetch all members of a mailing list.
pub fn list_members(&self, pk: i64) -> Result<Vec<DbVal<ListMembership>>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM membership WHERE list = ?;")?;
let list_iter = stmt.query_map([&pk], |row| {
let pk = row.get("pk")?;
Ok(DbVal(
ListMembership {
pk: row.get("pk")?,
list: row.get("list")?,
address: row.get("address")?,
name: row.get("name")?,
digest: row.get("digest")?,
enabled: row.get("enabled")?,
verified: row.get("verified")?,
hide_address: row.get("hide_address")?,
receive_duplicates: row.get("receive_duplicates")?,
receive_own_posts: row.get("receive_own_posts")?,
receive_confirmation: row.get("receive_confirmation")?,
},
pk,
))
})?;
let mut ret = vec![];
for list in list_iter {
let list = list?;
ret.push(list);
}
Ok(ret)
}
/// Fetch mailing list member.
pub fn list_member(&self, list_pk: i64, pk: i64) -> Result<DbVal<ListMembership>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM membership WHERE list = ? AND pk = ?;")?;
let ret = stmt.query_row([&list_pk, &pk], |row| {
let _pk: i64 = row.get("pk")?;
debug_assert_eq!(pk, _pk);
Ok(DbVal(
ListMembership {
pk,
list: row.get("list")?,
address: row.get("address")?,
name: row.get("name")?,
digest: row.get("digest")?,
enabled: row.get("enabled")?,
verified: row.get("verified")?,
hide_address: row.get("hide_address")?,
receive_duplicates: row.get("receive_duplicates")?,
receive_own_posts: row.get("receive_own_posts")?,
receive_confirmation: row.get("receive_confirmation")?,
},
pk,
))
})?;
Ok(ret)
}
/// Fetch mailing list member by their address.
pub fn list_member_by_address(
&self,
list_pk: i64,
address: &str,
) -> Result<DbVal<ListMembership>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM membership WHERE list = ? AND address = ?;")?;
let ret = stmt.query_row(rusqlite::params![&list_pk, &address], |row| {
let pk = row.get("pk")?;
let address_ = row.get("address")?;
debug_assert_eq!(address, &address_);
Ok(DbVal(
ListMembership {
pk,
list: row.get("list")?,
address: address_,
name: row.get("name")?,
digest: row.get("digest")?,
enabled: row.get("enabled")?,
verified: row.get("verified")?,
hide_address: row.get("hide_address")?,
receive_duplicates: row.get("receive_duplicates")?,
receive_own_posts: row.get("receive_own_posts")?,
receive_confirmation: row.get("receive_confirmation")?,
},
pk,
))
})?;
Ok(ret)
}
/// Add member to mailing list.
pub fn add_member(
&self,
list_pk: i64,
mut new_val: ListMembership,
) -> Result<DbVal<ListMembership>> {
new_val.list = list_pk;
let mut stmt = self
.connection
.prepare("INSERT INTO membership(list, address, name, enabled, digest, verified, hide_address, receive_duplicates, receive_own_posts, receive_confirmation) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *;").unwrap();
let ret = stmt.query_row(
rusqlite::params![
&new_val.list,
&new_val.address,
&new_val.name,
&new_val.enabled,
&new_val.digest,
&new_val.verified,
&new_val.hide_address,
&new_val.receive_duplicates,
&new_val.receive_own_posts,
&new_val.receive_confirmation
],
|row| {
let pk = row.get("pk")?;
Ok(DbVal(
ListMembership {
pk,
list: row.get("list")?,
address: row.get("address")?,
name: row.get("name")?,
digest: row.get("digest")?,
enabled: row.get("enabled")?,
verified: row.get("verified")?,
hide_address: row.get("hide_address")?,
receive_duplicates: row.get("receive_duplicates")?,
receive_own_posts: row.get("receive_own_posts")?,
receive_confirmation: row.get("receive_confirmation")?,
},
pk,
))
},
)?;
trace!("add_member {:?}.", &ret);
Ok(ret)
}
/// Create membership candidate.
pub fn add_candidate_member(
&mut self,
list_pk: i64,
mut new_val: ListMembership,
) -> Result<i64> {
new_val.list = list_pk;
let mut stmt = self
.connection
.prepare("INSERT INTO candidate_membership(list, address, name, accepted) VALUES(?, ?, ?, ?) RETURNING pk;")?;
let ret = stmt.query_row(
rusqlite::params![&new_val.list, &new_val.address, &new_val.name, None::<i64>,],
|row| {
let pk: i64 = row.get("pk")?;
Ok(pk)
},
)?;
drop(stmt);
trace!("add_candidate_member {:?}.", &ret);
self.accept_candidate_member(ret)?;
// [ref:FIXME]: add approval required option for subscriptions.
Ok(ret)
}
/// Accept membership candidate.
pub fn accept_candidate_member(&mut self, pk: i64) -> Result<DbVal<ListMembership>> {
let tx = self.connection.transaction()?;
let mut stmt = tx
.prepare("INSERT INTO membership(list, address, name, enabled, digest, verified, hide_address, receive_duplicates, receive_own_posts, receive_confirmation) SELECT list, address, name, 1, 0, 0, 0, 1, 1, 0 FROM candidate_membership WHERE pk = ? RETURNING *;")?;
let ret = stmt.query_row(rusqlite::params![&pk], |row| {
let pk = row.get("pk")?;
Ok(DbVal(
ListMembership {
pk,
list: row.get("list")?,
address: row.get("address")?,
name: row.get("name")?,
digest: row.get("digest")?,
enabled: row.get("enabled")?,
verified: row.get("verified")?,
hide_address: row.get("hide_address")?,
receive_duplicates: row.get("receive_duplicates")?,
receive_own_posts: row.get("receive_own_posts")?,
receive_confirmation: row.get("receive_confirmation")?,
},
pk,
))
})?;
drop(stmt);
tx.execute(
"UPDATE candidate_membership SET accepted = ? WHERE pk = ?;",
[&ret.pk, &pk],
)?;
tx.commit()?;
trace!("accept_candidate_member {:?}.", &ret);
Ok(ret)
}
/// Remove a member by their address.
pub fn remove_membership(&self, list_pk: i64, address: &str) -> Result<()> {
self.connection
.query_row(
"DELETE FROM membership WHERE list_pk = ? AND address = ? RETURNING *;",
rusqlite::params![&list_pk, &address],
|_| Ok(()),
)
.map_err(|err| {
if matches!(err, rusqlite::Error::QueryReturnedNoRows) {
Error::from(err).chain_err(|| NotFound("list or list owner not found!"))
} else {
err.into()
}
})?;
Ok(())
}
/// Update a mailing list membership.
pub fn update_member(&mut self, change_set: ListMembershipChangeset) -> Result<()> {
let pk = self
.list_member_by_address(change_set.list, &change_set.address)?
.pk;
if matches!(
change_set,
ListMembershipChangeset {
list: _,
address: _,
name: None,
digest: None,
verified: None,
hide_address: None,
receive_duplicates: None,
receive_own_posts: None,
receive_confirmation: None,
enabled: None,
}
) {
return Ok(());
}
let ListMembershipChangeset {
list,
address: _,
name,
digest,
enabled,
verified,
hide_address,
receive_duplicates,
receive_own_posts,
receive_confirmation,
} = change_set;
let tx = self.connection.transaction()?;
macro_rules! update {
($field:tt) => {{
if let Some($field) = $field {
tx.execute(
concat!(
"UPDATE membership SET ",
stringify!($field),
" = ? WHERE list = ? AND pk = ?;"
),
rusqlite::params![&$field, &list, &pk],
)?;
}
}};
}
update!(name);
update!(digest);
update!(enabled);
update!(verified);
update!(hide_address);
update!(receive_duplicates);
update!(receive_own_posts);
update!(receive_confirmation);
tx.commit()?;
Ok(())
}
}

View File

@ -20,7 +20,7 @@
use super::*;
pub use post_policy::*;
pub use subscribe_policy::*;
pub use subscription_policy::*;
mod post_policy {
use super::*;
@ -38,7 +38,7 @@ mod post_policy {
pk,
list: row.get("list")?,
announce_only: row.get("announce_only")?,
subscriber_only: row.get("subscriber_only")?,
subscription_only: row.get("subscription_only")?,
approval_needed: row.get("approval_needed")?,
open: row.get("open")?,
custom: row.get("custom")?,
@ -80,7 +80,7 @@ mod post_policy {
/// pk: 0,
/// list: list_pk,
/// announce_only: false,
/// subscriber_only: true,
/// subscription_only: true,
/// approval_needed: false,
/// open: false,
/// custom: false,
@ -131,7 +131,7 @@ mod post_policy {
/// Set the unique post policy for a list.
pub fn set_list_policy(&self, policy: PostPolicy) -> Result<DbVal<PostPolicy>> {
if !(policy.announce_only
|| policy.subscriber_only
|| policy.subscription_only
|| policy.approval_needed
|| policy.open
|| policy.custom)
@ -143,13 +143,13 @@ mod post_policy {
}
let list_pk = policy.list;
let mut stmt = self.connection.prepare("INSERT OR REPLACE INTO post_policy(list, announce_only, subscriber_only, approval_needed, open, custom) VALUES (?, ?, ?, ?, ?, ?) RETURNING *;")?;
let mut stmt = self.connection.prepare("INSERT OR REPLACE INTO post_policy(list, announce_only, subscription_only, approval_needed, open, custom) VALUES (?, ?, ?, ?, ?, ?) RETURNING *;")?;
let ret = stmt
.query_row(
rusqlite::params![
&list_pk,
&policy.announce_only,
&policy.subscriber_only,
&policy.subscription_only,
&policy.approval_needed,
&policy.open,
&policy.custom,
@ -161,7 +161,7 @@ mod post_policy {
pk,
list: row.get("list")?,
announce_only: row.get("announce_only")?,
subscriber_only: row.get("subscriber_only")?,
subscription_only: row.get("subscription_only")?,
approval_needed: row.get("approval_needed")?,
open: row.get("open")?,
custom: row.get("custom")?,
@ -194,20 +194,23 @@ mod post_policy {
}
}
mod subscribe_policy {
mod subscription_policy {
use super::*;
impl Connection {
/// Fetch the subscribe policy of a mailing list.
pub fn list_subscrbe_policy(&self, pk: i64) -> Result<Option<DbVal<SubscribePolicy>>> {
/// Fetch the subscription policy of a mailing list.
pub fn list_subscription_policy(
&self,
pk: i64,
) -> Result<Option<DbVal<SubscriptionPolicy>>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM subscribe_policy WHERE list = ?;")?;
.prepare("SELECT * FROM subscription_policy WHERE list = ?;")?;
let ret = stmt
.query_row([&pk], |row| {
let pk = row.get("pk")?;
Ok(DbVal(
SubscribePolicy {
SubscriptionPolicy {
pk,
list: row.get("list")?,
send_confirmation: row.get("send_confirmation")?,
@ -253,7 +256,7 @@ mod subscribe_policy {
/// pk: 0,
/// list: list_pk,
/// announce_only: false,
/// subscriber_only: true,
/// subscription_only: true,
/// approval_needed: false,
/// open: false,
/// custom: false,
@ -263,10 +266,10 @@ mod subscribe_policy {
/// # }
/// # do_test(config);
/// ```
pub fn remove_list_subscribe_policy(&self, list_pk: i64, policy_pk: i64) -> Result<()> {
let mut stmt = self
.connection
.prepare("DELETE FROM subscribe_policy WHERE pk = ? AND list = ? RETURNING *;")?;
pub fn remove_list_subscription_policy(&self, list_pk: i64, policy_pk: i64) -> Result<()> {
let mut stmt = self.connection.prepare(
"DELETE FROM subscription_policy WHERE pk = ? AND list = ? RETURNING *;",
)?;
stmt.query_row(rusqlite::params![&policy_pk, &list_pk,], |_| Ok(()))
.map_err(|err| {
if matches!(err, rusqlite::Error::QueryReturnedNoRows) {
@ -276,7 +279,7 @@ mod subscribe_policy {
}
})?;
trace!("remove_list_subscribe_policy {} {}.", list_pk, policy_pk);
trace!("remove_list_subscription_policy {} {}.", list_pk, policy_pk);
Ok(())
}
@ -299,13 +302,13 @@ mod subscribe_policy {
/// # do_test(config);
/// ```
#[cfg(doc)]
pub fn remove_list_subscribe_policy_panic() {}
pub fn remove_list_subscription_policy_panic() {}
/// Set the unique post policy for a list.
pub fn set_list_subscribe_policy(
pub fn set_list_subscription_policy(
&self,
policy: SubscribePolicy,
) -> Result<DbVal<SubscribePolicy>> {
policy: SubscriptionPolicy,
) -> Result<DbVal<SubscriptionPolicy>> {
if !(policy.open || policy.manual || policy.request || policy.custom) {
return Err(
"Cannot add empty policy. Having no policy is probably what you want to do."
@ -314,7 +317,7 @@ mod subscribe_policy {
}
let list_pk = policy.list;
let mut stmt = self.connection.prepare("INSERT OR REPLACE INTO subscribe_policy(list, send_confirmation, open, manual, request, custom) VALUES (?, ?, ?, ?, ?, ?) RETURNING *;")?;
let mut stmt = self.connection.prepare("INSERT OR REPLACE INTO subscription_policy(list, send_confirmation, open, manual, request, custom) VALUES (?, ?, ?, ?, ?, ?) RETURNING *;")?;
let ret = stmt
.query_row(
rusqlite::params![
@ -328,7 +331,7 @@ mod subscribe_policy {
|row| {
let pk = row.get("pk")?;
Ok(DbVal(
SubscribePolicy {
SubscriptionPolicy {
pk,
list: row.get("list")?,
send_confirmation: row.get("send_confirmation")?,
@ -359,7 +362,7 @@ mod subscribe_policy {
}
})?;
trace!("set_list_subscribe_policy {:?}.", &ret);
trace!("set_list_subscription_policy {:?}.", &ret);
Ok(ret)
}
}

View File

@ -150,14 +150,14 @@ impl Connection {
for mut list in lists {
trace!("Examining list {}", list.display_name());
let filters = self.list_filters(&list);
let memberships = self.list_members(list.pk)?;
let subscriptions = self.list_subscriptions(list.pk)?;
let owners = self.list_owners(list.pk)?;
trace!("List members {:#?}", &memberships);
trace!("List subscriptions {:#?}", &subscriptions);
let mut list_ctx = ListContext {
policy: self.list_policy(list.pk)?,
list_owners: &owners,
list: &mut list,
memberships: &memberships,
subscriptions: &subscriptions,
scheduled_jobs: vec![],
};
let mut post = Post {
@ -252,10 +252,11 @@ impl Connection {
.map(|p| p.approval_needed)
.unwrap_or(false);
for f in env.from() {
let membership = ListMembership {
let subscription = ListSubscription {
pk: 0,
list: list.pk,
address: f.get_email(),
account: None,
name: f.get_display_name(),
digest: false,
hide_address: false,
@ -266,12 +267,12 @@ impl Connection {
verified: true,
};
if approval_needed {
match self.add_candidate_member(list.pk, membership) {
match self.add_candidate_subscription(list.pk, subscription) {
Ok(_) => {}
Err(_err) => {}
}
//FIXME: send notification to list-owner
} else if let Err(_err) = self.add_member(list.pk, membership) {
} else if let Err(_err) = self.add_subscription(list.pk, subscription) {
//FIXME: send failure notice to f
} else {
//FIXME: send success notice
@ -285,7 +286,7 @@ impl Connection {
list
);
for f in env.from() {
if let Err(_err) = self.remove_membership(list.pk, &f.get_email()) {
if let Err(_err) = self.remove_subscription(list.pk, &f.get_email()) {
//FIXME: send failure notice to f
} else {
//FIXME: send success notice to f

View File

@ -0,0 +1,534 @@
/*
* 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/>.
*/
use super::*;
impl Connection {
/// Fetch all subscriptions of a mailing list.
pub fn list_subscriptions(&self, pk: i64) -> Result<Vec<DbVal<ListSubscription>>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM subscription WHERE list = ?;")?;
let list_iter = stmt.query_map([&pk], |row| {
let pk = row.get("pk")?;
Ok(DbVal(
ListSubscription {
pk: row.get("pk")?,
list: row.get("list")?,
address: row.get("address")?,
account: row.get("account")?,
name: row.get("name")?,
digest: row.get("digest")?,
enabled: row.get("enabled")?,
verified: row.get("verified")?,
hide_address: row.get("hide_address")?,
receive_duplicates: row.get("receive_duplicates")?,
receive_own_posts: row.get("receive_own_posts")?,
receive_confirmation: row.get("receive_confirmation")?,
},
pk,
))
})?;
let mut ret = vec![];
for list in list_iter {
let list = list?;
ret.push(list);
}
Ok(ret)
}
/// Fetch mailing list subscription.
pub fn list_subscription(&self, list_pk: i64, pk: i64) -> Result<DbVal<ListSubscription>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM subscription WHERE list = ? AND pk = ?;")?;
let ret = stmt.query_row([&list_pk, &pk], |row| {
let _pk: i64 = row.get("pk")?;
debug_assert_eq!(pk, _pk);
Ok(DbVal(
ListSubscription {
pk,
list: row.get("list")?,
address: row.get("address")?,
account: row.get("account")?,
name: row.get("name")?,
digest: row.get("digest")?,
enabled: row.get("enabled")?,
verified: row.get("verified")?,
hide_address: row.get("hide_address")?,
receive_duplicates: row.get("receive_duplicates")?,
receive_own_posts: row.get("receive_own_posts")?,
receive_confirmation: row.get("receive_confirmation")?,
},
pk,
))
})?;
Ok(ret)
}
/// Fetch mailing list subscription by their address.
pub fn list_subscription_by_address(
&self,
list_pk: i64,
address: &str,
) -> Result<DbVal<ListSubscription>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM subscription WHERE list = ? AND address = ?;")?;
let ret = stmt.query_row(rusqlite::params![&list_pk, &address], |row| {
let pk = row.get("pk")?;
let address_ = row.get("address")?;
debug_assert_eq!(address, &address_);
Ok(DbVal(
ListSubscription {
pk,
list: row.get("list")?,
address: address_,
account: row.get("account")?,
name: row.get("name")?,
digest: row.get("digest")?,
enabled: row.get("enabled")?,
verified: row.get("verified")?,
hide_address: row.get("hide_address")?,
receive_duplicates: row.get("receive_duplicates")?,
receive_own_posts: row.get("receive_own_posts")?,
receive_confirmation: row.get("receive_confirmation")?,
},
pk,
))
})?;
Ok(ret)
}
/// Add subscription to mailing list.
pub fn add_subscription(
&self,
list_pk: i64,
mut new_val: ListSubscription,
) -> Result<DbVal<ListSubscription>> {
new_val.list = list_pk;
let mut stmt = self
.connection
.prepare("INSERT INTO subscription(list, address, account, name, enabled, digest, verified, hide_address, receive_duplicates, receive_own_posts, receive_confirmation) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *;").unwrap();
let ret = stmt.query_row(
rusqlite::params![
&new_val.list,
&new_val.address,
&new_val.account,
&new_val.name,
&new_val.enabled,
&new_val.digest,
&new_val.verified,
&new_val.hide_address,
&new_val.receive_duplicates,
&new_val.receive_own_posts,
&new_val.receive_confirmation
],
|row| {
let pk = row.get("pk")?;
Ok(DbVal(
ListSubscription {
pk,
list: row.get("list")?,
address: row.get("address")?,
name: row.get("name")?,
account: row.get("account")?,
digest: row.get("digest")?,
enabled: row.get("enabled")?,
verified: row.get("verified")?,
hide_address: row.get("hide_address")?,
receive_duplicates: row.get("receive_duplicates")?,
receive_own_posts: row.get("receive_own_posts")?,
receive_confirmation: row.get("receive_confirmation")?,
},
pk,
))
},
)?;
trace!("add_subscription {:?}.", &ret);
Ok(ret)
}
/// Create subscription candidate.
pub fn add_candidate_subscription(
&mut self,
list_pk: i64,
mut new_val: ListSubscription,
) -> Result<i64> {
new_val.list = list_pk;
let mut stmt = self
.connection
.prepare("INSERT INTO candidate_subscription(list, address, name, accepted) VALUES(?, ?, ?, ?) RETURNING pk;")?;
let ret = stmt.query_row(
rusqlite::params![&new_val.list, &new_val.address, &new_val.name, None::<i64>,],
|row| {
let pk: i64 = row.get("pk")?;
Ok(pk)
},
)?;
drop(stmt);
trace!("add_candidate_subscription {:?}.", &ret);
self.accept_candidate_subscription(ret)?;
// [ref:FIXME]: add approval required option for subscriptions.
Ok(ret)
}
/// Accept subscription candidate.
pub fn accept_candidate_subscription(&mut self, pk: i64) -> Result<DbVal<ListSubscription>> {
let tx = self.connection.transaction()?;
let mut stmt = tx
.prepare("INSERT INTO subscription(list, address, name, enabled, digest, verified, hide_address, receive_duplicates, receive_own_posts, receive_confirmation) SELECT list, address, name, 1, 0, 0, 0, 1, 1, 0 FROM candidate_subscription WHERE pk = ? RETURNING *;")?;
let ret = stmt.query_row(rusqlite::params![&pk], |row| {
let pk = row.get("pk")?;
Ok(DbVal(
ListSubscription {
pk,
list: row.get("list")?,
address: row.get("address")?,
account: row.get("account")?,
name: row.get("name")?,
digest: row.get("digest")?,
enabled: row.get("enabled")?,
verified: row.get("verified")?,
hide_address: row.get("hide_address")?,
receive_duplicates: row.get("receive_duplicates")?,
receive_own_posts: row.get("receive_own_posts")?,
receive_confirmation: row.get("receive_confirmation")?,
},
pk,
))
})?;
drop(stmt);
tx.execute(
"UPDATE candidate_subscription SET accepted = ? WHERE pk = ?;",
[&ret.pk, &pk],
)?;
tx.commit()?;
trace!("accept_candidate_subscription {:?}.", &ret);
Ok(ret)
}
/// Remove a subscription by their address.
pub fn remove_subscription(&self, list_pk: i64, address: &str) -> Result<()> {
self.connection
.query_row(
"DELETE FROM subscription WHERE list = ? AND address = ? RETURNING *;",
rusqlite::params![&list_pk, &address],
|_| Ok(()),
)
.map_err(|err| {
if matches!(err, rusqlite::Error::QueryReturnedNoRows) {
Error::from(err).chain_err(|| NotFound("list or list owner not found!"))
} else {
err.into()
}
})?;
Ok(())
}
/// Update a mailing list subscription.
pub fn update_subscription(&mut self, change_set: ListSubscriptionChangeset) -> Result<()> {
let pk = self
.list_subscription_by_address(change_set.list, &change_set.address)?
.pk;
if matches!(
change_set,
ListSubscriptionChangeset {
list: _,
address: _,
account: None,
name: None,
digest: None,
verified: None,
hide_address: None,
receive_duplicates: None,
receive_own_posts: None,
receive_confirmation: None,
enabled: None,
}
) {
return Ok(());
}
let ListSubscriptionChangeset {
list,
address: _,
name,
account,
digest,
enabled,
verified,
hide_address,
receive_duplicates,
receive_own_posts,
receive_confirmation,
} = change_set;
let tx = self.connection.transaction()?;
macro_rules! update {
($field:tt) => {{
if let Some($field) = $field {
tx.execute(
concat!(
"UPDATE subscription SET ",
stringify!($field),
" = ? WHERE list = ? AND pk = ?;"
),
rusqlite::params![&$field, &list, &pk],
)?;
}
}};
}
update!(name);
update!(account);
update!(digest);
update!(enabled);
update!(verified);
update!(hide_address);
update!(receive_duplicates);
update!(receive_own_posts);
update!(receive_confirmation);
tx.commit()?;
Ok(())
}
/// Fetch account by pk.
pub fn account(&self, pk: i64) -> Result<Option<DbVal<Account>>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM account WHERE pk = ?;")?;
let ret = stmt
.query_row(rusqlite::params![&pk], |row| {
let _pk: i64 = row.get("pk")?;
debug_assert_eq!(pk, _pk);
Ok(DbVal(
Account {
pk,
name: row.get("name")?,
address: row.get("address")?,
public_key: row.get("public_key")?,
password: row.get("password")?,
enabled: row.get("enabled")?,
},
pk,
))
})
.optional()?;
Ok(ret)
}
/// Fetch account by address.
pub fn account_by_address(&self, address: &str) -> Result<Option<DbVal<Account>>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM account WHERE address = ?;")?;
let ret = stmt
.query_row(rusqlite::params![&address], |row| {
let pk = row.get("pk")?;
Ok(DbVal(
Account {
pk,
name: row.get("name")?,
address: row.get("address")?,
public_key: row.get("public_key")?,
password: row.get("password")?,
enabled: row.get("enabled")?,
},
pk,
))
})
.optional()?;
Ok(ret)
}
/// Fetch all subscriptions of an account by primary key.
pub fn account_subscriptions(&self, pk: i64) -> Result<Vec<DbVal<ListSubscription>>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM subscription WHERE account = ?;")?;
let list_iter = stmt.query_map([&pk], |row| {
let pk = row.get("pk")?;
Ok(DbVal(
ListSubscription {
pk: row.get("pk")?,
list: row.get("list")?,
address: row.get("address")?,
account: row.get("account")?,
name: row.get("name")?,
digest: row.get("digest")?,
enabled: row.get("enabled")?,
verified: row.get("verified")?,
hide_address: row.get("hide_address")?,
receive_duplicates: row.get("receive_duplicates")?,
receive_own_posts: row.get("receive_own_posts")?,
receive_confirmation: row.get("receive_confirmation")?,
},
pk,
))
})?;
let mut ret = vec![];
for list in list_iter {
let list = list?;
ret.push(list);
}
Ok(ret)
}
/// Fetch all accounts.
pub fn accounts(&self) -> Result<Vec<DbVal<Account>>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM account ORDER BY pk ASC;")?;
let list_iter = stmt.query_map([], |row| {
let pk = row.get("pk")?;
Ok(DbVal(
Account {
pk,
name: row.get("name")?,
address: row.get("address")?,
public_key: row.get("public_key")?,
password: row.get("password")?,
enabled: row.get("enabled")?,
},
pk,
))
})?;
let mut ret = vec![];
for list in list_iter {
let list = list?;
ret.push(list);
}
Ok(ret)
}
/// Add account.
pub fn add_account(&self, new_val: Account) -> Result<DbVal<Account>> {
let mut stmt = self
.connection
.prepare("INSERT INTO account(name, address, public_key, password, enabled) VALUES(?, ?, ?, ?, ?) RETURNING *;").unwrap();
let ret = stmt.query_row(
rusqlite::params![
&new_val.name,
&new_val.address,
&new_val.public_key,
&new_val.password,
&new_val.enabled,
],
|row| {
let pk = row.get("pk")?;
Ok(DbVal(
Account {
pk,
name: row.get("name")?,
address: row.get("address")?,
public_key: row.get("public_key")?,
password: row.get("password")?,
enabled: row.get("enabled")?,
},
pk,
))
},
)?;
trace!("add_account {:?}.", &ret);
Ok(ret)
}
/// Remove an account by their address.
pub fn remove_account(&self, address: &str) -> Result<()> {
self.connection
.query_row(
"DELETE FROM account WHERE address = ? RETURNING *;",
rusqlite::params![&address],
|_| Ok(()),
)
.map_err(|err| {
if matches!(err, rusqlite::Error::QueryReturnedNoRows) {
Error::from(err).chain_err(|| NotFound("account not found!"))
} else {
err.into()
}
})?;
Ok(())
}
/// Update an account.
pub fn update_account(&mut self, change_set: AccountChangeset) -> Result<()> {
let Some(acc) = self.account_by_address(&change_set.address)? else {
return Err(NotFound("account with this address not found!").into());
};
let pk = acc.pk;
if matches!(
change_set,
AccountChangeset {
address: _,
name: None,
public_key: None,
password: None,
enabled: None,
}
) {
return Ok(());
}
let AccountChangeset {
address: _,
name,
public_key,
password,
enabled,
} = change_set;
let tx = self.connection.transaction()?;
macro_rules! update {
($field:tt) => {{
if let Some($field) = $field {
tx.execute(
concat!(
"UPDATE account SET ",
stringify!($field),
" = ? WHERE pk = ?;"
),
rusqlite::params![&$field, &pk],
)?;
}
}};
}
update!(name);
update!(public_key);
update!(password);
update!(enabled);
tx.commit()?;
Ok(())
}
}

View File

@ -78,17 +78,17 @@
//! pk: 0,
//! list: list_pk,
//! announce_only: false,
//! subscriber_only: true,
//! subscription_only: true,
//! approval_needed: false,
//! open: false,
//! custom: false,
//! },
//! )?;
//!
//! // Drop privileges; we can only process new e-mail and modify memberships from now on.
//! // Drop privileges; we can only process new e-mail and modify subscriptions from now on.
//! let mut db = db.untrusted();
//!
//! assert_eq!(db.list_members(list_pk)?.len(), 0);
//! assert_eq!(db.list_subscriptions(list_pk)?.len(), 0);
//! assert_eq!(db.list_posts(list_pk, None)?.len(), 0);
//!
//! // Process a subscription request e-mail
@ -102,7 +102,7 @@
//! let envelope = melib::Envelope::from_bytes(subscribe_bytes, None)?;
//! db.post(&envelope, subscribe_bytes, /* dry_run */ false)?;
//!
//! assert_eq!(db.list_members(list_pk)?.len(), 1);
//! assert_eq!(db.list_subscriptions(list_pk)?.len(), 1);
//! assert_eq!(db.list_posts(list_pk, None)?.len(), 0);
//!
//! // Process a post
@ -118,7 +118,7 @@
//! melib::Envelope::from_bytes(post_bytes, None).expect("Could not parse message");
//! db.post(&envelope, post_bytes, /* dry_run */ false)?;
//!
//! assert_eq!(db.list_members(list_pk)?.len(), 1);
//! assert_eq!(db.list_subscriptions(list_pk)?.len(), 1);
//! assert_eq!(db.list_posts(list_pk, None)?.len(), 1);
//! # Ok(())
//! # }

View File

@ -50,8 +50,8 @@ pub struct ListContext<'list> {
pub list: &'list MailingList,
/// The mailing list owners.
pub list_owners: &'list [DbVal<ListOwner>],
/// The mailing list memberships.
pub memberships: &'list [DbVal<ListMembership>],
/// The mailing list subscriptions.
pub subscriptions: &'list [DbVal<ListSubscription>],
/// The mailing list post policy.
pub policy: Option<DbVal<PostPolicy>>,
/// The scheduled jobs added by each filter in a list's [`PostFilter`](message_filters::PostFilter) stack.
@ -122,7 +122,7 @@ pub enum ListRequest {
RetrieveArchive(String, String),
/// Request reception of specific mailing list posts from `Message-ID` values.
RetrieveMessages(Vec<String>),
/// Request change in digest preferences. (See [`ListMembership`])
/// Request change in digest preferences. (See [`ListSubscription`])
SetDigest(bool),
/// Other type of request.
Other(String),

View File

@ -78,15 +78,15 @@ impl PostFilter for PostRightsCheck {
};
return Err(());
}
} else if policy.subscriber_only {
trace!("post policy is subscriber_only");
} else if policy.subscription_only {
trace!("post policy is subscription_only");
let email_from = post.from.get_email();
trace!("post from is {:?}", &email_from);
trace!("post memberships are {:#?}", &ctx.memberships);
if !ctx.memberships.iter().any(|lm| lm.address == email_from) {
trace!("post subscriptions are {:#?}", &ctx.subscriptions);
if !ctx.subscriptions.iter().any(|lm| lm.address == email_from) {
trace!("Envelope from is not subscribed to this list");
post.action = PostAction::Reject {
reason: "Only subscribers can post to this list.".to_string(),
reason: "Only subscriptions can post to this list.".to_string(),
};
return Err(());
}
@ -94,8 +94,8 @@ impl PostFilter for PostRightsCheck {
trace!("post policy says approval_needed");
let email_from = post.from.get_email();
trace!("post from is {:?}", &email_from);
trace!("post memberships are {:#?}", &ctx.memberships);
if !ctx.memberships.iter().any(|lm| lm.address == email_from) {
trace!("post subscriptions are {:#?}", &ctx.subscriptions);
if !ctx.subscriptions.iter().any(|lm| lm.address == email_from) {
trace!("Envelope from is not subscribed to this list");
post.action = PostAction::Defer {
reason: "Your posting has been deferred. Approval from the list's moderators is required before it is submitted.".to_string(),
@ -143,7 +143,7 @@ impl PostFilter for AddListHeaders {
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.unsubscribe_header();
let list_unsubscribe = ctx.list.unsubscription_header();
let list_archive = ctx.list.archive_header();
if let Some(post) = list_post.as_ref() {
headers.push((&b"List-Post"[..], post.as_bytes()));
@ -189,7 +189,7 @@ impl PostFilter for ArchivedAtLink {
}
}
/// Assuming there are no more changes to be done on the post, it finalizes which list members
/// Assuming there are no more changes to be done on the post, it finalizes which list subscriptions
/// will receive the post in `post.action` field.
pub struct FinalizeRecipients;
impl PostFilter for FinalizeRecipients {
@ -202,21 +202,21 @@ impl PostFilter for FinalizeRecipients {
let mut recipients = vec![];
let mut digests = vec![];
let email_from = post.from.get_email();
for member in ctx.memberships {
trace!("examining member {:?}", &member);
if member.address == email_from {
trace!("member is submitter");
for subscription in ctx.subscriptions {
trace!("examining subscription {:?}", &subscription);
if subscription.address == email_from {
trace!("subscription is submitter");
}
if member.digest {
if member.address != email_from || member.receive_own_posts {
trace!("Member gets digest");
digests.push(member.address());
if subscription.digest {
if subscription.address != email_from || subscription.receive_own_posts {
trace!("Subscription gets digest");
digests.push(subscription.address());
}
continue;
}
if member.address != email_from || member.receive_own_posts {
trace!("Member gets copy");
recipients.push(member.address());
if subscription.address != email_from || subscription.receive_own_posts {
trace!("Subscription gets copy");
recipients.push(subscription.address());
}
// TODO:
// - check for duplicates (To,Cc,Bcc)

View File

@ -17,8 +17,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
//! Database models: [`MailingList`], [`ListOwner`], [`ListMembership`], [`PostPolicy`],
//! [`SubscribePolicy`] and [`Post`].
//! Database models: [`MailingList`], [`ListOwner`], [`ListSubscription`], [`PostPolicy`],
//! [`SubscriptionPolicy`] and [`Post`].
use super::*;
pub mod changesets;
@ -45,6 +45,12 @@ impl<T> std::ops::Deref for DbVal<T> {
}
}
impl<T> std::ops::DerefMut for DbVal<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl<T> std::fmt::Display for DbVal<T>
where
T: std::fmt::Display,
@ -105,7 +111,7 @@ impl MailingList {
/// Value of `List-Unsubscribe` header.
///
/// See RFC2369 Section 3.2: <https://www.rfc-editor.org/rfc/rfc2369#section-3.2>
pub fn unsubscribe_header(&self) -> Option<String> {
pub fn unsubscription_header(&self) -> Option<String> {
let p = self.address.split('@').collect::<Vec<&str>>();
Some(format!(
"<mailto:{}+request@{}?subject=subscribe>",
@ -126,7 +132,7 @@ impl MailingList {
}
/// List unsubscribe action as a [`MailtoAddress`](super::MailtoAddress).
pub fn unsubscribe_mailto(&self) -> MailtoAddress {
pub fn unsubscription_mailto(&self) -> MailtoAddress {
let p = self.address.split('@').collect::<Vec<&str>>();
MailtoAddress {
address: format!("{}+request@{}", p[0], p[1]),
@ -135,7 +141,7 @@ impl MailingList {
}
/// List subscribe action as a [`MailtoAddress`](super::MailtoAddress).
pub fn subscribe_mailto(&self) -> MailtoAddress {
pub fn subscription_mailto(&self) -> MailtoAddress {
let p = self.address.split('@').collect::<Vec<&str>>();
MailtoAddress {
address: format!("{}+request@{}", p[0], p[1]),
@ -158,36 +164,38 @@ impl MailingList {
}
}
/// A mailing list membership entry.
/// A mailing list subscription entry.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ListMembership {
pub struct ListSubscription {
/// Database primary key.
pub pk: i64,
/// Mailing list foreign key (See [`MailingList`]).
pub list: i64,
/// Member's e-mail address.
/// Subscription's e-mail address.
pub address: String,
/// Member's name, optional.
/// Subscription's name, optional.
pub name: Option<String>,
/// Whether this membership is enabled.
/// Subscription's account foreign key, optional.
pub account: Option<i64>,
/// Whether this subscription is enabled.
pub enabled: bool,
/// Whether the e-mail address is verified.
pub verified: bool,
/// Whether member wishes to receive list posts as a periodical digest e-mail.
/// Whether subscription wishes to receive list posts as a periodical digest e-mail.
pub digest: bool,
/// Whether member wishes their e-mail address hidden from public view.
/// Whether subscription wishes their e-mail address hidden from public view.
pub hide_address: bool,
/// Whether member wishes to receive mailing list post duplicates, i.e. posts addressed to them
/// Whether subscription wishes to receive mailing list post duplicates, i.e. posts addressed to them
/// and the mailing list to which they are subscribed.
pub receive_duplicates: bool,
/// Whether member wishes to receive their own mailing list posts from the mailing list, as a
/// Whether subscription wishes to receive their own mailing list posts from the mailing list, as a
/// confirmation.
pub receive_own_posts: bool,
/// Whether member wishes to receive a plain confirmation for their own mailing list posts.
/// Whether subscription wishes to receive a plain confirmation for their own mailing list posts.
pub receive_confirmation: bool,
}
impl std::fmt::Display for ListMembership {
impl std::fmt::Display for ListSubscription {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(
fmt,
@ -205,8 +213,8 @@ impl std::fmt::Display for ListMembership {
}
}
impl ListMembership {
/// Member address as a [`melib::Address`]
impl ListSubscription {
/// Subscription address as a [`melib::Address`]
pub fn address(&self) -> Address {
Address::new(self.name.clone(), self.address.clone())
}
@ -224,8 +232,8 @@ pub struct PostPolicy {
/// Whether the policy is announce only (Only list owners can submit posts, and everyone will
/// receive them).
pub announce_only: bool,
/// Whether the policy is "subscriber only" (Only list subscribers can post).
pub subscriber_only: bool,
/// Whether the policy is "subscription only" (Only list subscriptions can post).
pub subscription_only: bool,
/// Whether the policy is "approval needed" (Anyone can post, but approval from list owners is
/// required if they are not subscribed).
pub approval_needed: bool,
@ -261,13 +269,14 @@ impl std::fmt::Display for ListOwner {
}
}
impl From<ListOwner> for ListMembership {
impl From<ListOwner> for ListSubscription {
fn from(val: ListOwner) -> Self {
Self {
pk: 0,
list: val.list,
address: val.address,
name: val.name,
account: None,
digest: false,
hide_address: false,
receive_duplicates: true,
@ -317,7 +326,7 @@ impl std::fmt::Display for Post {
///
/// Only one of the policy boolean flags must be set to true.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SubscribePolicy {
pub struct SubscriptionPolicy {
/// Database primary key.
pub pk: i64,
/// Mailing list foreign key (See [`MailingList`]).
@ -326,7 +335,7 @@ pub struct SubscribePolicy {
pub send_confirmation: bool,
/// Anyone can subscribe without restrictions.
pub open: bool,
/// Only list owners can manually add subscribers.
/// Only list owners can manually add subscriptions.
pub manual: bool,
/// Anyone can request to subscribe.
pub request: bool,
@ -334,7 +343,30 @@ pub struct SubscribePolicy {
pub custom: bool,
}
impl std::fmt::Display for SubscribePolicy {
impl std::fmt::Display for SubscriptionPolicy {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{:?}", self)
}
}
/// An account entry.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Account {
/// Database primary key.
pub pk: i64,
/// Accounts's display name, optional.
pub name: Option<String>,
/// Account's e-mail address.
pub address: String,
/// GPG public key.
pub public_key: Option<String>,
/// SSH public key.
pub password: String,
/// Whether this account is enabled.
pub enabled: bool,
}
impl std::fmt::Display for Account {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{:?}", self)
}

View File

@ -19,6 +19,16 @@
//! Changeset structs: update specific struct fields.
macro_rules! impl_display {
($t:ty) => {
impl std::fmt::Display for $t {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{:?}", self)
}
}
};
}
/// Changeset struct for [`Mailinglist`](super::MailingList).
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MailingListChangeset {
@ -46,14 +56,18 @@ pub struct MailingListChangeset {
pub enabled: Option<bool>,
}
/// Changeset struct for [`ListMembership`](super::ListMembership).
impl_display!(MailingListChangeset);
/// Changeset struct for [`ListSubscription`](super::ListSubscription).
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ListMembershipChangeset {
pub struct ListSubscriptionChangeset {
/// Mailing list foreign key (See [`MailingList`](super::MailingList)).
pub list: i64,
/// Membership e-mail address.
/// Subscription e-mail address.
pub address: String,
/// Optional new value.
pub account: Option<Option<i64>>,
/// Optional new value.
pub name: Option<Option<String>>,
/// Optional new value.
pub digest: Option<bool>,
@ -71,6 +85,8 @@ pub struct ListMembershipChangeset {
pub receive_confirmation: Option<bool>,
}
impl_display!(ListSubscriptionChangeset);
/// Changeset struct for [`PostPolicy`](super::PostPolicy).
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PostPolicyChangeset {
@ -81,11 +97,13 @@ pub struct PostPolicyChangeset {
/// Optional new value.
pub announce_only: Option<bool>,
/// Optional new value.
pub subscriber_only: Option<bool>,
pub subscription_only: Option<bool>,
/// Optional new value.
pub approval_needed: Option<bool>,
}
impl_display!(PostPolicyChangeset);
/// Changeset struct for [`ListOwner`](super::ListOwner).
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ListOwnerChangeset {
@ -99,24 +117,21 @@ pub struct ListOwnerChangeset {
pub name: Option<Option<String>>,
}
impl std::fmt::Display for MailingListChangeset {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{:?}", self)
}
impl_display!(ListOwnerChangeset);
/// Changeset struct for [`Account`](super::Account).
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AccountChangeset {
/// Account e-mail address.
pub address: String,
/// Optional new value.
pub name: Option<Option<String>>,
/// Optional new value.
pub public_key: Option<Option<String>>,
/// Optional new value.
pub password: Option<String>,
/// Optional new value.
pub enabled: Option<Option<bool>>,
}
impl std::fmt::Display for ListMembershipChangeset {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{:?}", self)
}
}
impl std::fmt::Display for PostPolicyChangeset {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{:?}", self)
}
}
impl std::fmt::Display for ListOwnerChangeset {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{:?}", self)
}
}
impl_display!(AccountChangeset);

View File

@ -157,7 +157,7 @@ impl PostfixConfiguration {
},
Some(PostPolicy { .. }) => {
push_addr!(list.address);
push_addr!(list.subscribe_mailto().address);
push_addr!(list.subscription_mailto().address);
push_addr!(list.owner_mailto().address);
ret.push('\n');
}
@ -419,7 +419,7 @@ fn test_postfix_generation() -> Result<()> {
pk: 0,
list: second.pk(),
announce_only: false,
subscriber_only: false,
subscription_only: false,
approval_needed: false,
open: true,
custom: false,
@ -439,7 +439,7 @@ fn test_postfix_generation() -> Result<()> {
pk: 0,
list: third.pk(),
announce_only: false,
subscriber_only: false,
subscription_only: false,
approval_needed: true,
open: false,
custom: false,

View File

@ -31,17 +31,17 @@ CREATE TABLE IF NOT EXISTS post_policy (
pk INTEGER PRIMARY KEY NOT NULL,
list INTEGER NOT NULL UNIQUE,
announce_only BOOLEAN CHECK (announce_only in (0, 1)) NOT NULL DEFAULT 0,
subscriber_only BOOLEAN CHECK (subscriber_only in (0, 1)) NOT NULL DEFAULT 0,
subscription_only BOOLEAN CHECK (subscription_only in (0, 1)) NOT NULL DEFAULT 0,
approval_needed BOOLEAN CHECK (approval_needed in (0, 1)) NOT NULL DEFAULT 0,
open BOOLEAN CHECK (open in (0, 1)) NOT NULL DEFAULT 0,
custom BOOLEAN CHECK (custom in (0, 1)) NOT NULL DEFAULT 0,
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch())
CHECK(((custom) OR (((open) OR (((approval_needed) OR (((announce_only) OR (subscriber_only)) AND NOT ((announce_only) AND (subscriber_only)))) AND NOT ((approval_needed) AND (((announce_only) OR (subscriber_only)) AND NOT ((announce_only) AND (subscriber_only)))))) AND NOT ((open) AND (((approval_needed) OR (((announce_only) OR (subscriber_only)) AND NOT ((announce_only) AND (subscriber_only)))) AND NOT ((approval_needed) AND (((announce_only) OR (subscriber_only)) AND NOT ((announce_only) AND (subscriber_only)))))))) AND NOT ((custom) AND (((open) OR (((approval_needed) OR (((announce_only) OR (subscriber_only)) AND NOT ((announce_only) AND (subscriber_only)))) AND NOT ((approval_needed) AND (((announce_only) OR (subscriber_only)) AND NOT ((announce_only) AND (subscriber_only)))))) AND NOT ((open) AND (((approval_needed) OR (((announce_only) OR (subscriber_only)) AND NOT ((announce_only) AND (subscriber_only)))) AND NOT ((approval_needed) AND (((announce_only) OR (subscriber_only)) AND NOT ((announce_only) AND (subscriber_only))))))))),
CHECK(((custom) OR (((open) OR (((approval_needed) OR (((announce_only) OR (subscription_only)) AND NOT ((announce_only) AND (subscription_only)))) AND NOT ((approval_needed) AND (((announce_only) OR (subscription_only)) AND NOT ((announce_only) AND (subscription_only)))))) AND NOT ((open) AND (((approval_needed) OR (((announce_only) OR (subscription_only)) AND NOT ((announce_only) AND (subscription_only)))) AND NOT ((approval_needed) AND (((announce_only) OR (subscription_only)) AND NOT ((announce_only) AND (subscription_only)))))))) AND NOT ((custom) AND (((open) OR (((approval_needed) OR (((announce_only) OR (subscription_only)) AND NOT ((announce_only) AND (subscription_only)))) AND NOT ((approval_needed) AND (((announce_only) OR (subscription_only)) AND NOT ((announce_only) AND (subscription_only)))))) AND NOT ((open) AND (((approval_needed) OR (((announce_only) OR (subscription_only)) AND NOT ((announce_only) AND (subscription_only)))) AND NOT ((approval_needed) AND (((announce_only) OR (subscription_only)) AND NOT ((announce_only) AND (subscription_only))))))))),
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS subscribe_policy (
CREATE TABLE IF NOT EXISTS subscription_policy (
pk INTEGER PRIMARY KEY NOT NULL,
list INTEGER NOT NULL UNIQUE,
send_confirmation BOOLEAN CHECK (send_confirmation in (0, 1)) NOT NULL DEFAULT 1,
@ -55,7 +55,7 @@ CREATE TABLE IF NOT EXISTS subscribe_policy (
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS membership (
CREATE TABLE IF NOT EXISTS subscription (
pk INTEGER PRIMARY KEY NOT NULL,
list INTEGER NOT NULL,
address TEXT NOT NULL,
@ -86,7 +86,7 @@ CREATE TABLE IF NOT EXISTS account (
last_modified INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS candidate_membership (
CREATE TABLE IF NOT EXISTS candidate_subscription (
pk INTEGER PRIMARY KEY NOT NULL,
list INTEGER NOT NULL,
address TEXT NOT NULL,
@ -95,7 +95,7 @@ CREATE TABLE IF NOT EXISTS candidate_membership (
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
FOREIGN KEY (accepted) REFERENCES membership(pk) ON DELETE CASCADE,
FOREIGN KEY (accepted) REFERENCES subscription(pk) ON DELETE CASCADE,
UNIQUE (list, address) ON CONFLICT ROLLBACK
);
@ -162,29 +162,46 @@ CREATE TABLE IF NOT EXISTS queue (
CREATE TABLE IF NOT EXISTS bounce (
pk INTEGER PRIMARY KEY NOT NULL,
member INTEGER NOT NULL UNIQUE,
subscription INTEGER NOT NULL UNIQUE,
count INTEGER NOT NULL DEFAULT 0,
last_bounce TEXT NOT NULL DEFAULT (datetime()),
FOREIGN KEY (member) REFERENCES membership(pk) ON DELETE CASCADE
FOREIGN KEY (subscription) REFERENCES subscription(pk) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS post_listpk_idx ON post(list);
CREATE INDEX IF NOT EXISTS post_msgid_idx ON post(message_id);
CREATE INDEX IF NOT EXISTS list_idx ON list(id);
CREATE INDEX IF NOT EXISTS membership_idx ON membership(address);
CREATE INDEX IF NOT EXISTS subscription_idx ON subscription(address);
CREATE TRIGGER IF NOT EXISTS accept_candidate AFTER INSERT ON membership
CREATE TRIGGER IF NOT EXISTS accept_candidate AFTER INSERT ON subscription
FOR EACH ROW
BEGIN
UPDATE candidate_membership SET accepted = NEW.pk, last_modified = unixepoch()
WHERE candidate_membership.list = NEW.list AND candidate_membership.address = NEW.address;
UPDATE candidate_subscription SET accepted = NEW.pk, last_modified = unixepoch()
WHERE candidate_subscription.list = NEW.list AND candidate_subscription.address = NEW.address;
END;
CREATE TRIGGER IF NOT EXISTS verify_candidate AFTER INSERT ON membership
CREATE TRIGGER IF NOT EXISTS verify_candidate AFTER INSERT ON subscription
FOR EACH ROW
BEGIN
UPDATE membership SET verified = 0, last_modified = unixepoch()
WHERE membership.pk = NEW.pk AND EXISTS (SELECT 1 FROM list WHERE pk = NEW.list AND verify = 1);
UPDATE subscription SET verified = 0, last_modified = unixepoch()
WHERE subscription.pk = NEW.pk AND EXISTS (SELECT 1 FROM list WHERE pk = NEW.list AND verify = 1);
END;
CREATE TRIGGER IF NOT EXISTS add_account AFTER INSERT ON account
FOR EACH ROW
BEGIN
UPDATE subscription SET account = NEW.pk, last_modified = unixepoch()
WHERE subscription.address = NEW.address;
END;
CREATE TRIGGER IF NOT EXISTS add_account_to_subscription AFTER INSERT ON subscription
FOR EACH ROW
BEGIN
UPDATE subscription
SET account = acc.pk,
last_modified = unixepoch()
FROM (SELECT * FROM account) AS acc
WHERE subscription.account = acc.address;
END;
CREATE TRIGGER IF NOT EXISTS last_modified_list AFTER UPDATE ON list
@ -193,42 +210,49 @@ BEGIN
UPDATE list SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
CREATE TRIGGER IF NOT EXISTS last_modified_owner AFTER UPDATE ON owner
FOR EACH ROW
BEGIN
UPDATE owner SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
CREATE TRIGGER IF NOT EXISTS last_modified_post_policy AFTER UPDATE ON post_policy
FOR EACH ROW
BEGIN
UPDATE post_policy SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
CREATE TRIGGER IF NOT EXISTS last_modified_subscribe_policy AFTER UPDATE ON subscribe_policy
CREATE TRIGGER IF NOT EXISTS last_modified_subscription_policy AFTER UPDATE ON subscription_policy
FOR EACH ROW
BEGIN
UPDATE subscribe_policy SET last_modified = unixepoch()
UPDATE subscription_policy SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
CREATE TRIGGER IF NOT EXISTS last_modified_membership AFTER UPDATE ON membership
CREATE TRIGGER IF NOT EXISTS last_modified_subscription AFTER UPDATE ON subscription
FOR EACH ROW
BEGIN
UPDATE membership SET last_modified = unixepoch()
UPDATE subscription SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
CREATE TRIGGER IF NOT EXISTS last_modified_account AFTER UPDATE ON account
FOR EACH ROW
BEGIN
UPDATE account SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
CREATE TRIGGER IF NOT EXISTS last_modified_candidate_membership AFTER UPDATE ON candidate_membership
CREATE TRIGGER IF NOT EXISTS last_modified_candidate_subscription AFTER UPDATE ON candidate_subscription
FOR EACH ROW
BEGIN
UPDATE candidate_membership SET last_modified = unixepoch()
UPDATE candidate_subscription SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
CREATE TRIGGER IF NOT EXISTS last_modified_templates AFTER UPDATE ON templates
FOR EACH ROW
BEGIN

View File

@ -41,17 +41,17 @@ CREATE TABLE IF NOT EXISTS post_policy (
pk INTEGER PRIMARY KEY NOT NULL,
list INTEGER NOT NULL UNIQUE,
BOOLEAN_TYPE(announce_only) DEFAULT BOOLEAN_FALSE(),
BOOLEAN_TYPE(subscriber_only) DEFAULT BOOLEAN_FALSE(),
BOOLEAN_TYPE(subscription_only) DEFAULT BOOLEAN_FALSE(),
BOOLEAN_TYPE(approval_needed) DEFAULT BOOLEAN_FALSE(),
BOOLEAN_TYPE(open) DEFAULT BOOLEAN_FALSE(),
BOOLEAN_TYPE(custom) DEFAULT BOOLEAN_FALSE(),
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch())
CHECK(xor(custom, xor(open, xor(approval_needed, xor(announce_only, subscriber_only))))),
CHECK(xor(custom, xor(open, xor(approval_needed, xor(announce_only, subscription_only))))),
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS subscribe_policy (
CREATE TABLE IF NOT EXISTS subscription_policy (
pk INTEGER PRIMARY KEY NOT NULL,
list INTEGER NOT NULL UNIQUE,
BOOLEAN_TYPE(send_confirmation) DEFAULT BOOLEAN_TRUE(),
@ -65,7 +65,7 @@ CREATE TABLE IF NOT EXISTS subscribe_policy (
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS membership (
CREATE TABLE IF NOT EXISTS subscription (
pk INTEGER PRIMARY KEY NOT NULL,
list INTEGER NOT NULL,
address TEXT NOT NULL,
@ -96,7 +96,7 @@ CREATE TABLE IF NOT EXISTS account (
last_modified INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS candidate_membership (
CREATE TABLE IF NOT EXISTS candidate_subscription (
pk INTEGER PRIMARY KEY NOT NULL,
list INTEGER NOT NULL,
address TEXT NOT NULL,
@ -105,7 +105,7 @@ CREATE TABLE IF NOT EXISTS candidate_membership (
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
FOREIGN KEY (accepted) REFERENCES membership(pk) ON DELETE CASCADE,
FOREIGN KEY (accepted) REFERENCES subscription(pk) ON DELETE CASCADE,
UNIQUE (list, address) ON CONFLICT ROLLBACK
);
@ -172,36 +172,60 @@ CREATE TABLE IF NOT EXISTS queue (
CREATE TABLE IF NOT EXISTS bounce (
pk INTEGER PRIMARY KEY NOT NULL,
member INTEGER NOT NULL UNIQUE,
subscription INTEGER NOT NULL UNIQUE,
count INTEGER NOT NULL DEFAULT 0,
last_bounce TEXT NOT NULL DEFAULT (datetime()),
FOREIGN KEY (member) REFERENCES membership(pk) ON DELETE CASCADE
FOREIGN KEY (subscription) REFERENCES subscription(pk) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS post_listpk_idx ON post(list);
CREATE INDEX IF NOT EXISTS post_msgid_idx ON post(message_id);
CREATE INDEX IF NOT EXISTS list_idx ON list(id);
CREATE INDEX IF NOT EXISTS membership_idx ON membership(address);
CREATE INDEX IF NOT EXISTS subscription_idx ON subscription(address);
CREATE TRIGGER IF NOT EXISTS accept_candidate AFTER INSERT ON membership
CREATE TRIGGER IF NOT EXISTS accept_candidate AFTER INSERT ON subscription
FOR EACH ROW
BEGIN
UPDATE candidate_membership SET accepted = NEW.pk, last_modified = unixepoch()
WHERE candidate_membership.list = NEW.list AND candidate_membership.address = NEW.address;
UPDATE candidate_subscription SET accepted = NEW.pk, last_modified = unixepoch()
WHERE candidate_subscription.list = NEW.list AND candidate_subscription.address = NEW.address;
END;
CREATE TRIGGER IF NOT EXISTS verify_candidate AFTER INSERT ON membership
CREATE TRIGGER IF NOT EXISTS verify_candidate AFTER INSERT ON subscription
FOR EACH ROW
BEGIN
UPDATE membership SET verified = BOOLEAN_FALSE(), last_modified = unixepoch()
WHERE membership.pk = NEW.pk AND EXISTS (SELECT 1 FROM list WHERE pk = NEW.list AND verify = BOOLEAN_TRUE());
UPDATE subscription SET verified = BOOLEAN_FALSE(), last_modified = unixepoch()
WHERE subscription.pk = NEW.pk AND EXISTS (SELECT 1 FROM list WHERE pk = NEW.list AND verify = BOOLEAN_TRUE());
END;
CREATE TRIGGER IF NOT EXISTS add_account AFTER INSERT ON account
FOR EACH ROW
BEGIN
UPDATE subscription SET account = NEW.pk, last_modified = unixepoch()
WHERE subscription.address = NEW.address;
END;
CREATE TRIGGER IF NOT EXISTS add_account_to_subscription AFTER INSERT ON subscription
FOR EACH ROW
BEGIN
UPDATE subscription
SET account = acc.pk,
last_modified = unixepoch()
FROM (SELECT * FROM account) AS acc
WHERE subscription.account = acc.address;
END;
update_last_modified(`list')
update_last_modified(`owner')
update_last_modified(`post_policy')
update_last_modified(`subscribe_policy')
update_last_modified(`membership')
update_last_modified(`subscription_policy')
update_last_modified(`subscription')
update_last_modified(`account')
update_last_modified(`candidate_membership')
update_last_modified(`candidate_subscription')
update_last_modified(`templates')

View File

@ -54,7 +54,7 @@ fn test_authorizer() {
pk: 0,
list: 1,
announce_only: false,
subscriber_only: true,
subscription_only: true,
approval_needed: false,
open: false,
custom: false,
@ -97,7 +97,7 @@ fn test_authorizer() {
pk: 0,
list: 1,
announce_only: false,
subscriber_only: true,
subscription_only: true,
approval_needed: false,
open: false,
custom: false,

View File

@ -65,7 +65,7 @@ fn test_error_queue() {
pk: 0,
list: foo_chat.pk(),
announce_only: false,
subscriber_only: true,
subscription_only: true,
approval_needed: false,
open: false,
custom: false,

View File

@ -225,7 +225,7 @@ fn test_smtp() {
pk: 0,
list: foo_chat.pk(),
announce_only: false,
subscriber_only: true,
subscription_only: true,
approval_needed: false,
open: false,
custom: false,
@ -244,18 +244,19 @@ fn test_smtp() {
.kind()
{
mailpot::ErrorKind::PostRejected(reason) => {
trace!("Non-member post succesfully rejected: '{reason}'");
trace!("Non-subscription post succesfully rejected: '{reason}'");
}
other => panic!("Got unexpected error: {}", other),
}
db.add_member(
db.add_subscription(
foo_chat.pk(),
ListMembership {
ListSubscription {
pk: 0,
list: foo_chat.pk(),
address: "japoeunp@hotmail.com".into(),
name: Some("Jamaica Poe".into()),
account: None,
digest: false,
verified: true,
hide_address: false,
@ -266,13 +267,14 @@ fn test_smtp() {
},
)
.unwrap();
db.add_member(
db.add_subscription(
foo_chat.pk(),
ListMembership {
ListSubscription {
pk: 0,
list: foo_chat.pk(),
address: "manos@example.com".into(),
name: Some("Manos Hands".into()),
account: None,
digest: false,
verified: true,
hide_address: false,
@ -345,7 +347,7 @@ fn test_smtp_mailcrab() {
pk: 0,
list: foo_chat.pk(),
announce_only: false,
subscriber_only: true,
subscription_only: true,
approval_needed: false,
open: false,
custom: false,
@ -363,17 +365,18 @@ fn test_smtp_mailcrab() {
.kind()
{
mailpot::ErrorKind::PostRejected(reason) => {
trace!("Non-member post succesfully rejected: '{reason}'");
trace!("Non-subscription post succesfully rejected: '{reason}'");
}
other => panic!("Got unexpected error: {}", other),
}
db.add_member(
db.add_subscription(
foo_chat.pk(),
ListMembership {
ListSubscription {
pk: 0,
list: foo_chat.pk(),
address: "japoeunp@hotmail.com".into(),
name: Some("Jamaica Poe".into()),
account: None,
digest: false,
verified: true,
hide_address: false,
@ -384,13 +387,14 @@ fn test_smtp_mailcrab() {
},
)
.unwrap();
db.add_member(
db.add_subscription(
foo_chat.pk(),
ListMembership {
ListSubscription {
pk: 0,
list: foo_chat.pk(),
address: "manos@example.com".into(),
name: Some("Manos Hands".into()),
account: None,
digest: false,
verified: true,
hide_address: false,

View File

@ -57,7 +57,7 @@ fn test_list_subscription() {
pk: 0,
list: foo_chat.pk(),
announce_only: false,
subscriber_only: true,
subscription_only: true,
approval_needed: false,
open: false,
custom: false,
@ -66,7 +66,7 @@ fn test_list_subscription() {
assert_eq!(post_policy.pk(), 1);
assert_eq!(db.error_queue().unwrap().len(), 0);
assert_eq!(db.list_members(foo_chat.pk()).unwrap().len(), 0);
assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 0);
let mut db = db.untrusted();
@ -114,7 +114,7 @@ MIME-Version: 1.0
melib::Envelope::from_bytes(input_bytes_2, None).expect("Could not parse message");
db.post(&envelope, input_bytes_2, /* dry_run */ false)
.unwrap();
assert_eq!(db.list_members(foo_chat.pk()).unwrap().len(), 1);
assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 1);
assert_eq!(db.error_queue().unwrap().len(), 1);
let envelope =
melib::Envelope::from_bytes(input_bytes_1, None).expect("Could not parse message");

View File

@ -134,26 +134,26 @@ Selects mailing list to operate on.
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.\fB
.SS mpot list members
.SS mpot list subscriptions
.\fR
.br
.br
List members of list.
List subscriptions of list.
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.\fB
.SS mpot list add-member
.SS mpot list add-subscription
.\fR
.br
.br
mpot list add\-member \-\-address \fIADDRESS\fR [\-\-name \fINAME\fR] [\-\-digest \fIDIGEST\fR] [\-\-hide\-address \fIHIDE_ADDRESS\fR] [\-\-verified \fIVERIFIED\fR] [\-\-receive\-confirmation \fIRECEIVE_CONFIRMATION\fR] [\-\-receive\-duplicates \fIRECEIVE_DUPLICATES\fR] [\-\-receive\-own\-posts \fIRECEIVE_OWN_POSTS\fR] [\-\-enabled \fIENABLED\fR]
mpot list add\-subscription \-\-address \fIADDRESS\fR [\-\-name \fINAME\fR] [\-\-digest \fIDIGEST\fR] [\-\-hide\-address \fIHIDE_ADDRESS\fR] [\-\-verified \fIVERIFIED\fR] [\-\-receive\-confirmation \fIRECEIVE_CONFIRMATION\fR] [\-\-receive\-duplicates \fIRECEIVE_DUPLICATES\fR] [\-\-receive\-own\-posts \fIRECEIVE_OWN_POSTS\fR] [\-\-enabled \fIENABLED\fR]
.br
Add member to list.
Add subscription to list.
.TP
\-\-address \fIADDRESS\fR
E\-mail address.
@ -226,32 +226,32 @@ Is subscription enabled.
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.\fB
.SS mpot list remove-member
.SS mpot list remove-subscription
.\fR
.br
.br
mpot list remove\-member \-\-address \fIADDRESS\fR
mpot list remove\-subscription \-\-address \fIADDRESS\fR
.br
Remove member from list.
Remove subscription from list.
.TP
\-\-address \fIADDRESS\fR
E\-mail address.
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.\fB
.SS mpot list update-membership
.SS mpot list update-subscription
.\fR
.br
.br
mpot list update\-membership [\-\-name \fINAME\fR] [\-\-digest \fIDIGEST\fR] [\-\-hide\-address \fIHIDE_ADDRESS\fR] [\-\-verified \fIVERIFIED\fR] [\-\-receive\-confirmation \fIRECEIVE_CONFIRMATION\fR] [\-\-receive\-duplicates \fIRECEIVE_DUPLICATES\fR] [\-\-receive\-own\-posts \fIRECEIVE_OWN_POSTS\fR] [\-\-enabled \fIENABLED\fR] \fIADDRESS\fR
mpot list update\-subscription [\-\-name \fINAME\fR] [\-\-digest \fIDIGEST\fR] [\-\-hide\-address \fIHIDE_ADDRESS\fR] [\-\-verified \fIVERIFIED\fR] [\-\-receive\-confirmation \fIRECEIVE_CONFIRMATION\fR] [\-\-receive\-duplicates \fIRECEIVE_DUPLICATES\fR] [\-\-receive\-own\-posts \fIRECEIVE_OWN_POSTS\fR] [\-\-enabled \fIENABLED\fR] \fIADDRESS\fR
.br
Update membership info.
Update subscription info.
.TP
\fIADDRESS\fR
Address to edit.
@ -330,7 +330,7 @@ Is subscription enabled.
.br
mpot list add\-policy [\-\-announce\-only \fIANNOUNCE_ONLY\fR] [\-\-subscriber\-only \fISUBSCRIBER_ONLY\fR] [\-\-approval\-needed \fIAPPROVAL_NEEDED\fR] [\-\-open \fIOPEN\fR] [\-\-custom \fICUSTOM\fR]
mpot list add\-policy [\-\-announce\-only \fIANNOUNCE_ONLY\fR] [\-\-subscription\-only \fISUBSCRIPTION_ONLY\fR] [\-\-approval\-needed \fIAPPROVAL_NEEDED\fR] [\-\-open \fIOPEN\fR] [\-\-custom \fICUSTOM\fR]
.br
Add a new post policy.
@ -338,11 +338,11 @@ Add a new post policy.
\-\-announce\-only
Only list owners can post.
.TP
\-\-subscriber\-only
Only subscribers can post.
\-\-subscription\-only
Only subscriptions can post.
.TP
\-\-approval\-needed
Subscribers can post. Other posts must be approved by list owners.
Subscriptions can post. Other posts must be approved by list owners.
.TP
\-\-open
Anyone can post without restrictions.
@ -385,7 +385,7 @@ Send confirmation e\-mail when subscription is finalized.
Anyone can subscribe without restrictions.
.TP
\-\-manual
Only list owners can manually add subscribers.
Only list owners can manually add subscriptions.
.TP
\-\-request
Anyone can request to subscribe.
@ -444,35 +444,35 @@ List owner primary key.
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.\fB
.SS mpot list enable-membership
.SS mpot list enable-subscription
.\fR
.br
.br
mpot list enable\-membership \fIADDRESS\fR
mpot list enable\-subscription \fIADDRESS\fR
.br
Alias for update\-membership \-\-enabled true.
Alias for update\-subscription \-\-enabled true.
.TP
\fIADDRESS\fR
Member address.
Subscription address.
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.\fB
.SS mpot list disable-membership
.SS mpot list disable-subscription
.\fR
.br
.br
mpot list disable\-membership \fIADDRESS\fR
mpot list disable\-subscription \fIADDRESS\fR
.br
Alias for update\-membership \-\-enabled false.
Alias for update\-subscription \-\-enabled false.
.TP
\fIADDRESS\fR
Member address.
Subscription address.
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.\fB
@ -511,7 +511,7 @@ New request address local part. If empty, it defaults to \*(Aq+request\*(Aq.
\-\-verify \fIVERIFY\fR
Require verification of e\-mails for new subscriptions.
Subscriptions that are initiated from the member\*(Aqs address are verified automatically.
Subscriptions that are initiated from the subscription\*(Aqs address are verified automatically.
.br
.br
@ -762,5 +762,115 @@ A postfix service is a daemon managed by the postfix process. Each entry in the
The `master.cf` file is documented in [`master(5)`](https://www.postfix.org/master.5.html): <https://www.postfix.org/master.5.html>.
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.\fB
.SS mpot accounts
.\fR
.br
.br
All Accounts.
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.\fB
.SS mpot account-info
.\fR
.br
.br
mpot account\-info \fIADDRESS\fR
.br
Account info.
.TP
\fIADDRESS\fR
Account address.
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.\fB
.SS mpot add-account
.\fR
.br
.br
mpot add\-account \-\-address \fIADDRESS\fR \-\-password \fIPASSWORD\fR [\-\-name \fINAME\fR] [\-\-public\-key \fIPUBLIC_KEY\fR] [\-\-enabled \fIENABLED\fR]
.br
Add account.
.TP
\-\-address \fIADDRESS\fR
E\-mail address.
.TP
\-\-password \fIPASSWORD\fR
SSH public key for authentication.
.TP
\-\-name \fINAME\fR
Name.
.TP
\-\-public\-key \fIPUBLIC_KEY\fR
Public key.
.TP
\-\-enabled \fIENABLED\fR
Is account enabled.
.br
.br
.br
[\fIpossible values: \fRtrue, false]
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.\fB
.SS mpot remove-account
.\fR
.br
.br
mpot remove\-account \-\-address \fIADDRESS\fR
.br
Remove account.
.TP
\-\-address \fIADDRESS\fR
E\-mail address.
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.\fB
.SS mpot update-account
.\fR
.br
.br
mpot update\-account [\-\-password \fIPASSWORD\fR] [\-\-name \fINAME\fR] [\-\-public\-key \fIPUBLIC_KEY\fR] [\-\-enabled \fIENABLED\fR] \fIADDRESS\fR
.br
Update account info.
.TP
\fIADDRESS\fR
Address to edit.
.TP
\-\-password \fIPASSWORD\fR
Public key for authentication.
.TP
\-\-name \fINAME\fR
Name.
.TP
\-\-public\-key \fIPUBLIC_KEY\fR
Public key.
.TP
\-\-enabled \fIENABLED\fR
Is account enabled.
.br
.br
.br
[\fIpossible values: \fRtrue, false]
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.SH AUTHORS
Manos Pitsidianakis <el13635@mail.ntua.gr>

View File

@ -58,10 +58,10 @@ async fn main() {
});
let conf4 = conf.clone();
//get("/lists/<num>/members")]
let lists_members = warp::path!("lists" / i64 / "members").map(move |list_pk| {
//get("/lists/<num>/subscriptions")]
let lists_subscriptions = warp::path!("lists" / i64 / "subscriptions").map(move |list_pk| {
let db = Connection::open_db(conf4.clone()).unwrap();
db.list_members(list_pk)
db.list_subscriptions(list_pk)
.ok()
.map(|l| warp::reply::json(&l))
.unwrap()
@ -84,7 +84,7 @@ async fn main() {
lists
.or(policy)
.or(lists_num)
.or(lists_members)
.or(lists_subscriptions)
.or(lists_owners)
.or(lists_owner_add),
);

32
web/Cargo.toml 100644
View File

@ -0,0 +1,32 @@
[package]
name = "mailpot-web"
version = "0.0.0+2023-04-07"
authors = ["Manos Pitsidianakis <el13635@mail.ntua.gr>"]
edition = "2021"
license = "LICENSE"
readme = "README.md"
description = "mailing list manager"
repository = "https://github.com/meli/mailpot"
keywords = ["mail", "mailing-lists"]
categories = ["email"]
[[bin]]
name = "mpot-web"
path = "src/main.rs"
[dependencies]
axum = { version = "^0.6" }
axum-login = { version = "^0.5" }
axum-sessions = { version = "^0.5" }
chrono = { version = "^0.4" }
eyre = { version = "0.6" }
http = "0.2"
lazy_static = "^1.4"
mailpot = { version = "^0.0", path = "../core" }
minijinja = { version = "0.31.0", features = ["source", ] }
percent-encoding = { version = "^2.1" }
rand = { version = "^0.8", features = ["min_const_gen"] }
serde = { version = "^1", features = ["derive", ] }
serde_json = "^1"
tempfile = { version = "^3.5" }
tokio = { version = "1", features = ["full"] }

12
web/README.md 100644
View File

@ -0,0 +1,12 @@
# mailpot REST http server
```shell
cargo run --bin mpot-archives
```
## generate static files
```shell
# mpot-gen CONF_FILE OUTPUT_DIR OPTIONAL_ROOT_URL_PREFIX
cargo run --bin mpot-gen -- ../conf.toml ./out/ "/mailpot"
```

422
web/src/auth.rs 100644
View File

@ -0,0 +1,422 @@
/*
* 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/>.
*/
use super::*;
use std::borrow::Cow;
use tempfile::NamedTempFile;
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use std::process::Stdio;
const TOKEN_KEY: &str = "ssh_challenge";
const EXPIRY_IN_SECS: i64 = 6 * 60;
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, PartialOrd)]
pub enum Role {
User,
Admin,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct User {
/// SSH signature.
pub ssh_signature: String,
/// User role.
pub role: Role,
/// Database primary key.
pub pk: i64,
/// Accounts's display name, optional.
pub name: Option<String>,
/// Account's e-mail address.
pub address: String,
/// GPG public key.
pub public_key: Option<String>,
/// SSH public key.
pub password: String,
/// Whether this account is enabled.
pub enabled: bool,
}
impl AuthUser<i64, Role> for User {
fn get_id(&self) -> i64 {
self.pk
}
fn get_password_hash(&self) -> SecretVec<u8> {
SecretVec::new(self.ssh_signature.clone().into())
}
fn get_role(&self) -> Option<Role> {
Some(self.role)
}
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Default)]
pub struct AuthFormPayload {
pub address: String,
pub password: String,
}
pub async fn ssh_signin(
mut session: WritableSession,
auth: AuthContext,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
if auth.current_user.is_some() {
if let Err(err) = session.add_message(Message {
message: "You are already logged in.".into(),
level: Level::Info,
}) {
return err.into_response();
}
return Redirect::to("/settings/").into_response();
}
let now: i64 = chrono::offset::Utc::now().timestamp();
let prev_token = if let Some(tok) = session.get::<(String, i64)>(TOKEN_KEY) {
let timestamp: i64 = tok.1;
if !(timestamp < now && now - timestamp < EXPIRY_IN_SECS) {
session.remove(TOKEN_KEY);
None
} else {
Some(tok)
}
} else {
None
};
let (token, timestamp): (String, i64) = if let Some(tok) = prev_token {
tok
} else {
use rand::distributions::Alphanumeric;
use rand::{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 timeout_left = ((timestamp + EXPIRY_IN_SECS) - now) as f64 / 60.0;
let root_url_prefix = &state.root_url_prefix;
let crumbs = vec![
Crumb {
label: "Lists".into(),
url: "/".into(),
},
Crumb {
label: "Sign in".into(),
url: "/login/".into(),
},
];
let context = minijinja::context! {
namespace => &state.public_url,
title => state.site_title.as_ref(),
page_title => "Log in",
description => "",
root_url_prefix => &root_url_prefix,
ssh_challenge => token,
timeout_left => timeout_left,
current_user => auth.current_user,
messages => session.drain_messages(),
crumbs => crumbs,
};
Html(
TEMPLATES
.get_template("auth.html")
.unwrap()
.render(context)
.unwrap_or_else(|err| err.to_string()),
)
.into_response()
}
pub async fn ssh_signin_post(
mut session: WritableSession,
mut auth: AuthContext,
Form(payload): Form<AuthFormPayload>,
state: Arc<AppState>,
) -> Result<Redirect, ResponseError> {
if auth.current_user.as_ref().is_some() {
session.add_message(Message {
message: "You are already logged in.".into(),
level: Level::Info,
})?;
return Ok(Redirect::to("/settings/"));
}
let now: i64 = chrono::offset::Utc::now().timestamp();
let (prev_token, _) =
if let Some(tok @ (_, timestamp)) = session.get::<(String, i64)>(TOKEN_KEY) {
if !(timestamp < now && now - timestamp < EXPIRY_IN_SECS) {
session.add_message(Message {
message: "The token has expired. Please retry.".into(),
level: Level::Error,
})?;
return Ok(Redirect::to("/login/"));
} else {
tok
}
} else {
session.add_message(Message {
message: "The token has expired. Please retry.".into(),
level: Level::Error,
})?;
return Ok(Redirect::to("/login/"));
};
drop(session);
let db = Connection::open_db(state.conf.clone())?;
let mut acc = match db
.account_by_address(&payload.address)
.with_status(StatusCode::BAD_REQUEST)?
{
Some(v) => v,
None => {
return Err(ResponseError::new(
format!("Account for {} not found", payload.address),
StatusCode::NOT_FOUND,
));
}
};
let sig = SshSignature {
email: payload.address.clone(),
ssh_public_key: acc.password.clone(),
ssh_signature: payload.password.clone(),
namespace: "lists.mailpot.rs".into(),
token: prev_token,
};
ssh_keygen(sig).await?;
let user = User {
pk: acc.pk(),
ssh_signature: payload.password,
role: Role::User,
public_key: std::mem::take(&mut acc.public_key),
password: std::mem::take(&mut acc.password),
name: std::mem::take(&mut acc.name),
address: payload.address,
enabled: acc.enabled,
};
state.insert_user(acc.pk(), user.clone()).await;
auth.login(&user)
.await
.map_err(|err| ResponseError::new(err.to_string(), StatusCode::BAD_REQUEST))?;
Ok(Redirect::to(&format!(
"{}/settings/",
state.root_url_prefix
)))
}
#[derive(Debug, Clone, Default)]
pub struct SshSignature {
pub email: String,
pub ssh_public_key: String,
pub ssh_signature: String,
pub namespace: Cow<'static, str>,
pub token: String,
}
/// Run ssh signature validation with `ssh-keygen` binary.
///
/// ```no_run
/// use mailpot_web::{ssh_keygen, SshSignature};
///
/// async fn key_gen(
/// ssh_public_key: String,
/// ssh_signature: String,
/// ) -> std::result::Result<(), Box<dyn std::error::Error>> {
/// let mut sig = SshSignature {
/// email: "user@example.com".to_string(),
/// ssh_public_key,
/// ssh_signature,
/// namespace: "doc-test@example.com".into(),
/// token: "d074a61990".to_string(),
/// };
///
/// ssh_keygen(sig.clone()).await?;
/// Ok(())
/// }
/// ```
pub async fn ssh_keygen(sig: SshSignature) -> Result<(), Box<dyn std::error::Error>> {
let SshSignature {
email,
ssh_public_key,
ssh_signature,
namespace,
token,
} = sig;
let dir = tempfile::tempdir()?;
let mut allowed_signers_fp = NamedTempFile::new_in(dir.path())?;
let mut signature_fp = NamedTempFile::new_in(dir.path())?;
{
let (tempfile, path) = allowed_signers_fp.into_parts();
let mut file = File::from(tempfile);
file.write_all(format!("{email} {ssh_public_key}").as_bytes())
.await?;
file.flush().await?;
allowed_signers_fp = NamedTempFile::from_parts(file.into_std().await, path);
}
{
let (tempfile, path) = signature_fp.into_parts();
let mut file = File::from(tempfile);
file.write_all(ssh_signature.trim().replace("\r\n", "\n").as_bytes())
.await?;
file.flush().await?;
signature_fp = NamedTempFile::from_parts(file.into_std().await, path);
}
let mut cmd = Command::new("ssh-keygen");
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
cmd.stdin(Stdio::piped());
// Once you have your allowed signers file, verification works like this:
// ssh-keygen -Y verify -f allowed_signers -I alice@example.com -n file -s file_to_verify.sig < file_to_verify
// Here are the arguments you may need to change:
// allowed_signers is the path to the allowed signers file.
// alice@example.com is the email address of the person who allegedly signed the file. This email address is looked up in the allowed signers file to get possible public keys.
// file is the "namespace", which must match the namespace used for signing as described above.
// file_to_verify.sig is the path to the signature file.
// file_to_verify is the path to the file to be verified. Note that this file is read from standard in. In the above command, the < shell operator is used to redirect standard in from this file.
// If the signature is valid, the command exits with status 0 and prints a message like this:
// Good "file" signature for alice@example.com with ED25519 key SHA256:ZGa8RztddW4kE2XKPPsP9ZYC7JnMObs6yZzyxg8xZSk
// Otherwise, the command exits with a non-zero status and prints an error message.
let mut child = cmd
.arg("-Y")
.arg("verify")
.arg("-f")
.arg(allowed_signers_fp.path())
.arg("-I")
.arg(&email)
.arg("-n")
.arg(namespace.as_ref())
.arg("-s")
.arg(signature_fp.path())
.spawn()
.expect("failed to spawn command");
let mut stdin = child
.stdin
.take()
.expect("child did not have a handle to stdin");
stdin
.write_all(token.as_bytes())
.await
.expect("could not write to stdin");
drop(stdin);
let op = child.wait_with_output().await?;
if !op.status.success() {
return Err(format!(
"ssh-keygen exited with {}:\nstdout: {}\n\nstderr: {}",
op.status.code().unwrap_or(-1),
String::from_utf8_lossy(&op.stdout),
String::from_utf8_lossy(&op.stderr)
)
.into());
}
Ok(())
}
pub async fn logout_handler(mut auth: AuthContext, State(state): State<Arc<AppState>>) -> Redirect {
auth.logout().await;
Redirect::to(&format!("{}/settings/", state.root_url_prefix))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_ssh_keygen() {
const PKEY: &str = concat!("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCzXp8nLJL8GPNw7S+Dqt0m3Dw/",
"xFOAdwKXcekTFI9cLDEUII2rNPf0uUZTpv57OgU+",
"QOEEIvWMjz+5KSWBX8qdP8OtV0QNvynlZkEKZN0cUqGKaNXo5a+PUDyiJ2rHroPe1aMo6mUBL9kLR6J2U1CYD/dLfL8ywXsAGmOL0bsK0GRPVBJAjpUNRjpGU/",
"2FFIlU6s6GawdbDXEHDox/UoOVAKIlhKabaTrFBA0ACFLRX2/GCBmHqqt5d4ZZjefYzReLs/beOjafYImoyhHC428wZDcUjvLrpSJbIOE/",
"gSPCWlRbcsxg4JGcKOtALUurE+ok+avy9M7eFjGhLGSlTKLdshIVQr/3W667M7bYfOT6xP/",
"lyjxeWIUYyj7rjlqKJ9tzygek7QNxCtuqH5xsZAZqzQCN8wfrPAlwDykvWityKOw+Bt2DWjimITqyKgsBsOaA+",
"eVCllFvooJxoYvAjODASjAUoOdgVzyBDpFnOhLFYiIIyL3F6NROS9i7z086paX7mrzcQzvLr4ckF9qT7DrI88ikISCR9bFR4vPq3aH",
"zJdjDDpWxACa5b11NG8KdCJPe/L0kDw82Q00U13CpW9FI9sZjvk+",
"lyw8bTFvVsIl6A0ueboFvrNvznAqHrtfWu75fXRh5sKj2TGk8rhm3vyNgrBSr5zAfFVM8LgqBxbAAYw==");
const SIG: &str = concat!(
"-----BEGIN SSH SIGNATURE-----\n",
"U1NIU0lHAAAAAQAAAhcAAAAHc3NoLXJzYQAAAAMBAAEAAAIBALNenycskvwY83DtL4Oq3S\n",
"bcPD/EU4B3Apdx6RMUj1wsMRQgjas09/S5RlOm/ns6BT5A4QQi9YyPP7kpJYFfyp0/w61X\n",
"RA2/KeVmQQpk3RxSoYpo1ejlr49QPKInaseug97VoyjqZQEv2QtHonZTUJgP90t8vzLBew\n",
"AaY4vRuwrQZE9UEkCOlQ1GOkZT/YUUiVTqzoZrB1sNcQcOjH9Sg5UAoiWEpptpOsUEDQAI\n",
"UtFfb8YIGYeqq3l3hlmN59jNF4uz9t46Np9giajKEcLjbzBkNxSO8uulIlsg4T+BI8JaVF\n",
"tyzGDgkZwo60AtS6sT6iT5q/L0zt4WMaEsZKVMot2yEhVCv/dbrrsztth85PrE/+XKPF5Y\n",
"hRjKPuuOWoon23PKB6TtA3EK26ofnGxkBmrNAI3zB+s8CXAPKS9aK3Io7D4G3YNaOKYhOr\n",
"IqCwGw5oD55UKWUW+ignGhi8CM4MBKMBSg52BXPIEOkWc6EsViIgjIvcXo1E5L2LvPTzql\n",
"pfuavNxDO8uvhyQX2pPsOsjzyKQhIJH1sVHi8+rdofMl2MMOlbEAJrlvXU0bwp0Ik978vS\n",
"QPDzZDTRTXcKlb0Uj2xmO+T6XLDxtMW9WwiXoDS55ugW+s2/OcCoeu19a7vl9dGHmwqPZM\n",
"aTyuGbe/I2CsFKvnMB8VUzwuCoHFsABjAAAAFGRvYy10ZXN0QGV4YW1wbGUuY29tAAAAAA\n",
"AAAAZzaGE1MTIAAAIUAAAADHJzYS1zaGEyLTUxMgAAAgBxaMqIfeapKTrhQzggDssD+76s\n",
"jZxv3XxzgsuAjlIdtw+/nyxU6skTnrGoam2shpmQvx0HuqSQ7HyS2USBK7T4LZNoE53zR/\n",
"ZmHLGoyQAoexiHSEW9Lk53kyRNPhpXQedTvm8REHPGM3zw6WO6mAXVVxvebvawf81LTbBb\n",
"p9ubNRcHgktVeywMO/sD6zWSyShq1gjVv1PdRBOjUgqkwjImL8dFKi1QUeoffCxyk3JhTO\n",
"siTy79HZSz/kOvkvL1vQuqaP2R8lE9P1uaD19dGOMTPRod3u+QmpYX47ri5KM3Fmkfxdwq\n",
"p8JVmfAA9nme7bmNS1hWgmF2Nbh9qjh1zOZvCimIpuNtz5eEl9K+1DxG6w5tX86wSGvBMO\n",
"znx0k1gGfkiAULqgrkdul7mqMPRvPN9J6QlNJ7SLFChRhzlJIJc6tOvCs7qkVD43Zcb+I5\n",
"Z+K4NiFf5jf8kVX/pjjeW/ucbrctJIkGsZ58OkHKi1EDRcq7NtCF6SKlcv8g3fMLd9wW6K\n",
"aaed0TBDC+s+f6naNIGvWqfWCwDuK5xGyDTTmJGcrsMwWuT9K6uLk8cGdv7t5mOFuWi5jl\n",
"E+IKZKVABMuWqSj96ErMIiBjtsAZfNSezpsK49wQztoSPhdwLhD6fHrSAyPCqN2xRkcsIb\n",
"6PxWKC/OELf3gyEBRPouxsF7xSZQ==\n",
"-----END SSH SIGNATURE-----\n"
);
const NAMESPACE: &str = "doc-test@example.com";
let mut sig = SshSignature {
email: "user@example.com".to_string(),
ssh_public_key: PKEY.to_string(),
ssh_signature: SIG.to_string(),
namespace: "doc-test@example.com".into(),
token: "d074a61990".to_string(),
};
ssh_keygen(sig.clone()).await.unwrap();
sig.ssh_signature = sig.ssh_signature.replace("J", "0");
let err = ssh_keygen(sig).await.unwrap_err();
assert!(
err.to_string().starts_with("ssh-keygen exited with"),
"{}",
err
);
}
}

244
web/src/cal.rs 100644
View File

@ -0,0 +1,244 @@
// MIT License
//
// Copyright (c) 2021 sadnessOjisan
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
use chrono::*;
#[allow(dead_code)]
/// Generate a calendar view of the given date's month.
///
/// Each vector element is an array of seven numbers representing weeks (starting on Sundays),
/// and each value is the numeric date.
/// A value of zero means a date that not exists in the current month.
///
/// # Examples
/// ```
/// use chrono::*;
/// use mailpot_web::calendarize;
///
/// let date = NaiveDate::parse_from_str("2021-01-02", "%Y-%m-%d").unwrap();
/// // Week = [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
/// println!("{:?}", calendarize(date));
/// // [0, 0, 0, 0, 0, 1, 2],
/// // [3, 4, 5, 6, 7, 8, 9],
/// // [10, 11, 12, 13, 14, 15, 16],
/// // [17, 18, 19, 20, 21, 22, 23],
/// // [24, 25, 26, 27, 28, 29, 30],
/// // [31, 0, 0, 0, 0, 0, 0]
/// ```
pub fn calendarize(date: NaiveDate) -> Vec<[u32; 7]> {
calendarize_with_offset(date, 0)
}
/// Generate a calendar view of the given date's month and offset.
///
/// Each vector element is an array of seven numbers representing weeks (starting on Sundays),
/// and each value is the numeric date.
/// A value of zero means a date that not exists in the current month.
///
/// Offset means the number of days from sunday.
/// For example, 1 means monday, 6 means saturday.
///
/// # Examples
/// ```
/// use chrono::*;
/// use mailpot_web::calendarize_with_offset;
///
/// let date = NaiveDate::parse_from_str("2021-01-02", "%Y-%m-%d").unwrap();
/// // Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
/// println!("{:?}", calendarize_with_offset(date, 1));
/// // [0, 0, 0, 0, 1, 2, 3],
/// // [4, 5, 6, 7, 8, 9, 10],
/// // [11, 12, 13, 14, 15, 16, 17],
/// // [18, 19, 20, 21, 22, 23, 24],
/// // [25, 26, 27, 28, 29, 30, 0],
/// ```
pub fn calendarize_with_offset(date: NaiveDate, offset: u32) -> Vec<[u32; 7]> {
let mut monthly_calendar: Vec<[u32; 7]> = Vec::with_capacity(6);
let year = date.year();
let month = date.month();
let num_days_from_sunday = NaiveDate::from_ymd_opt(year, month, 1)
.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);
} else {
first_date_day = 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()
.unwrap()
.day();
let mut date: u32 = 0;
while date < end_date {
let mut week: [u32; 7] = [0; 7];
for day in first_date_day..7 {
date += 1;
week[day as usize] = date;
if date >= end_date {
break;
}
}
first_date_day = 0;
monthly_calendar.push(week);
}
monthly_calendar
}
#[test]
fn january() {
let date = NaiveDate::parse_from_str("2021-01-02", "%Y-%m-%d").unwrap();
let actual = calendarize(date);
assert_eq!(
vec![
[0, 0, 0, 0, 0, 1, 2],
[3, 4, 5, 6, 7, 8, 9],
[10, 11, 12, 13, 14, 15, 16],
[17, 18, 19, 20, 21, 22, 23],
[24, 25, 26, 27, 28, 29, 30],
[31, 0, 0, 0, 0, 0, 0]
],
actual
);
}
#[test]
// Week = [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
fn with_offset_from_sunday() {
let date = NaiveDate::parse_from_str("2019-11-01", "%Y-%m-%d").unwrap();
let actual = calendarize_with_offset(date, 0);
assert_eq!(
vec![
[0, 0, 0, 0, 0, 1, 2],
[3, 4, 5, 6, 7, 8, 9],
[10, 11, 12, 13, 14, 15, 16],
[17, 18, 19, 20, 21, 22, 23],
[24, 25, 26, 27, 28, 29, 30],
],
actual
);
}
#[test]
// Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
fn with_offset_from_monday() {
let date = NaiveDate::parse_from_str("2019-11-01", "%Y-%m-%d").unwrap();
let actual = calendarize_with_offset(date, 1);
assert_eq!(
vec![
[0, 0, 0, 0, 1, 2, 3],
[4, 5, 6, 7, 8, 9, 10],
[11, 12, 13, 14, 15, 16, 17],
[18, 19, 20, 21, 22, 23, 24],
[25, 26, 27, 28, 29, 30, 0],
],
actual
);
}
#[test]
// Week = [Sat, Sun, Mon, Tue, Wed, Thu, Fri]
fn with_offset_from_saturday() {
let date = NaiveDate::parse_from_str("2019-11-01", "%Y-%m-%d").unwrap();
let actual = calendarize_with_offset(date, 6);
assert_eq!(
vec![
[0, 0, 0, 0, 0, 0, 1],
[2, 3, 4, 5, 6, 7, 8],
[9, 10, 11, 12, 13, 14, 15],
[16, 17, 18, 19, 20, 21, 22],
[23, 24, 25, 26, 27, 28, 29],
[30, 0, 0, 0, 0, 0, 0]
],
actual
);
}
#[test]
// Week = [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
fn with_offset_from_sunday_with7() {
let date = NaiveDate::parse_from_str("2019-11-01", "%Y-%m-%d").unwrap();
let actual = calendarize_with_offset(date, 7);
assert_eq!(
vec![
[0, 0, 0, 0, 0, 1, 2],
[3, 4, 5, 6, 7, 8, 9],
[10, 11, 12, 13, 14, 15, 16],
[17, 18, 19, 20, 21, 22, 23],
[24, 25, 26, 27, 28, 29, 30],
],
actual
);
}
#[test]
fn april() {
let date = NaiveDate::parse_from_str("2021-04-02", "%Y-%m-%d").unwrap();
let actual = calendarize(date);
assert_eq!(
vec![
[0, 0, 0, 0, 1, 2, 3],
[4, 5, 6, 7, 8, 9, 10],
[11, 12, 13, 14, 15, 16, 17],
[18, 19, 20, 21, 22, 23, 24],
[25, 26, 27, 28, 29, 30, 0]
],
actual
);
}
#[test]
fn uruudoshi() {
let date = NaiveDate::parse_from_str("2020-02-02", "%Y-%m-%d").unwrap();
let actual = calendarize(date);
assert_eq!(
vec![
[0, 0, 0, 0, 0, 0, 1],
[2, 3, 4, 5, 6, 7, 8],
[9, 10, 11, 12, 13, 14, 15],
[16, 17, 18, 19, 20, 21, 22],
[23, 24, 25, 26, 27, 28, 29]
],
actual
);
}
#[test]
fn uruwanaidoshi() {
let date = NaiveDate::parse_from_str("2021-02-02", "%Y-%m-%d").unwrap();
let actual = calendarize(date);
assert_eq!(
vec![
[0, 1, 2, 3, 4, 5, 6],
[7, 8, 9, 10, 11, 12, 13],
[14, 15, 16, 17, 18, 19, 20],
[21, 22, 23, 24, 25, 26, 27],
[28, 0, 0, 0, 0, 0, 0]
],
actual
);
}

169
web/src/lib.rs 100644
View File

@ -0,0 +1,169 @@
/*
* 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/>.
*/
pub use axum::{
extract::{Path, State},
handler::Handler,
response::{Html, IntoResponse, Redirect},
routing::{get, post},
Extension, Form, Router,
};
pub use http::{Request, Response, StatusCode};
pub use axum_login::{
memory_store::MemoryStore as AuthMemoryStore, secrecy::SecretVec, AuthLayer, AuthUser,
RequireAuthorizationLayer,
};
pub use axum_sessions::{
async_session::MemoryStore,
extractors::{ReadableSession, WritableSession},
SessionLayer,
};
pub type AuthContext =
axum_login::extractors::AuthContext<i64, auth::User, Arc<AppState>, auth::Role>;
pub type RequireAuth = RequireAuthorizationLayer<i64, auth::User, auth::Role>;
use chrono::Datelike;
use minijinja::value::{Object, Value};
use minijinja::{Environment, Error, Source};
use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
pub use mailpot::models::DbVal;
pub use mailpot::*;
pub use std::result::Result;
pub mod auth;
pub mod cal;
pub mod settings;
pub mod utils;
pub use auth::*;
pub use cal::calendarize;
pub use cal::*;
pub use settings::*;
pub use utils::*;
#[derive(Debug)]
pub struct ResponseError {
pub inner: Box<dyn std::error::Error>,
pub status: StatusCode,
}
impl std::fmt::Display for ResponseError {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "Inner: {}, status: {}", self.inner, self.status)
}
}
impl ResponseError {
pub fn new(msg: String, status: StatusCode) -> Self {
Self {
inner: Box::<dyn std::error::Error + Send + Sync>::from(msg),
status,
}
}
}
impl<E: Into<Box<dyn std::error::Error>>> From<E> for ResponseError {
fn from(err: E) -> Self {
Self {
inner: err.into(),
status: StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
pub trait IntoResponseError {
fn with_status(self, status: StatusCode) -> ResponseError;
}
impl<E: Into<Box<dyn std::error::Error>>> IntoResponseError for E {
fn with_status(self, status: StatusCode) -> ResponseError {
ResponseError {
status,
..ResponseError::from(self)
}
}
}
impl IntoResponse for ResponseError {
fn into_response(self) -> axum::response::Response {
let Self { inner, status } = self;
(status, inner.to_string()).into_response()
}
}
pub trait IntoResponseErrorResult<R> {
fn with_status(self, status: StatusCode) -> std::result::Result<R, ResponseError>;
}
impl<R, E> IntoResponseErrorResult<R> for std::result::Result<R, E>
where
E: IntoResponseError,
{
fn with_status(self, status: StatusCode) -> std::result::Result<R, ResponseError> {
self.map_err(|err| err.with_status(status))
}
}
#[derive(Clone)]
pub struct AppState {
pub conf: Configuration,
pub root_url_prefix: String,
pub public_url: String,
pub site_title: Cow<'static, str>,
pub user_store: Arc<RwLock<HashMap<i64, User>>>,
// ...
}
mod auth_impls {
use super::*;
type UserId = i64;
type User = auth::User;
type Role = auth::Role;
impl AppState {
pub async fn insert_user(&self, pk: UserId, user: User) {
self.user_store.write().await.insert(pk, user);
}
}
#[axum::async_trait]
impl axum_login::UserStore<UserId, Role> for Arc<AppState>
where
User: axum_login::AuthUser<UserId, Role>,
{
type User = User;
async fn load_user(
&self,
user_id: &UserId,
) -> std::result::Result<Option<Self::User>, eyre::Report> {
Ok(self.user_store.read().await.get(user_id).cloned())
}
}
}

252
web/src/main.rs 100644
View File

@ -0,0 +1,252 @@
/*
* 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/>.
*/
use mailpot_web::*;
use rand::Rng;
use minijinja::value::Value;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
#[tokio::main]
async fn main() {
let config_path = std::env::args()
.nth(1)
.expect("Expected configuration file path as first argument.");
let conf = Configuration::from_file(config_path).unwrap();
let store = MemoryStore::new();
let secret = rand::thread_rng().gen::<[u8; 128]>();
let session_layer = SessionLayer::new(store, &secret).with_secure(false);
let shared_state = Arc::new(AppState {
conf,
root_url_prefix: String::new(),
public_url: "lists.mailpot.rs".into(),
site_title: "mailing list archive".into(),
user_store: Arc::new(RwLock::new(HashMap::default())),
});
let auth_layer = AuthLayer::new(shared_state.clone(), &secret);
let app = Router::new()
.route("/", get(root))
.route("/lists/:pk/", get(list))
.route("/lists/:pk/edit/", get(list_edit))
.route("/help/", get(help))
.route(
"/login/",
get(auth::ssh_signin).post({
let shared_state = Arc::clone(&shared_state);
move |session, auth, body| auth::ssh_signin_post(session, auth, body, shared_state)
}),
)
.route("/logout/", get(logout_handler))
.route(
"/settings/",
get({
let shared_state = Arc::clone(&shared_state);
move |session, user| settings(session, user, shared_state)
}
.layer(RequireAuth::login()))
.post(
{
let shared_state = Arc::clone(&shared_state);
move |session, auth, body| settings_post(session, auth, body, shared_state)
}
.layer(RequireAuth::login()),
),
)
.route(
"/settings/list/:pk/",
get(user_list_subscription)
.layer(RequireAuth::login_with_role(Role::User..))
.post({
let shared_state = Arc::clone(&shared_state);
move |session, path, user, body| {
user_list_subscription_post(session, path, user, body, shared_state)
}
})
.layer(RequireAuth::login_with_role(Role::User..)),
)
.layer(auth_layer)
.layer(session_layer)
.with_state(shared_state);
// run it with hyper on localhost:3000
axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
.serve(app.into_make_service())
.await
.unwrap();
}
async fn root(
mut session: WritableSession,
auth: AuthContext,
State(state): State<Arc<AppState>>,
) -> Result<Html<String>, ResponseError> {
let db = Connection::open_db(state.conf.clone())?;
let lists_values = db.lists()?;
let lists = lists_values
.iter()
.map(|list| {
let months = db.months(list.pk)?;
let posts = db.list_posts(list.pk, None)?;
Ok(minijinja::context! {
name => &list.name,
posts => &posts,
months => &months,
body => &list.description.as_deref().unwrap_or_default(),
root_url_prefix => &state.root_url_prefix,
list => Value::from_object(MailingList::from(list.clone())),
})
})
.collect::<Result<Vec<_>, mailpot::Error>>()?;
let crumbs = vec![Crumb {
label: "Lists".into(),
url: "/".into(),
}];
let context = minijinja::context! {
title => state.site_title.as_ref(),
page_title => Option::<&'static str>::None,
description => "",
lists => &lists,
root_url_prefix => &state.root_url_prefix,
current_user => auth.current_user,
messages => session.drain_messages(),
crumbs => crumbs,
};
Ok(Html(TEMPLATES.get_template("lists.html")?.render(context)?))
}
async fn list(
mut session: WritableSession,
Path(id): Path<i64>,
auth: AuthContext,
State(state): State<Arc<AppState>>,
) -> Result<Html<String>, ResponseError> {
let db = Connection::open_db(state.conf.clone())?;
let list = db.list(id)?;
let post_policy = db.list_policy(list.pk)?;
let subscription_policy = db.list_subscription_policy(list.pk)?;
let months = db.months(list.pk)?;
let user_context = auth
.current_user
.as_ref()
.map(|user| db.list_subscription_by_address(id, &user.address).ok());
let posts = db.list_posts(list.pk, None)?;
let mut hist = months
.iter()
.map(|m| (m.to_string(), [0usize; 31]))
.collect::<HashMap<String, [usize; 31]>>();
let posts_ctx = posts
.iter()
.map(|post| {
//2019-07-14T14:21:02
if let Some(day) = post.datetime.get(8..10).and_then(|d| d.parse::<u64>().ok()) {
hist.get_mut(&post.month_year).unwrap()[day.saturating_sub(1) as usize] += 1;
}
let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None)
.expect("Could not parse mail");
let mut msg_id = &post.message_id[1..];
msg_id = &msg_id[..msg_id.len().saturating_sub(1)];
let subject = envelope.subject();
let mut subject_ref = subject.trim();
if subject_ref.starts_with('[')
&& subject_ref[1..].starts_with(&list.id)
&& subject_ref[1 + list.id.len()..].starts_with(']')
{
subject_ref = subject_ref[2 + list.id.len()..].trim();
}
minijinja::context! {
pk => post.pk,
list => post.list,
subject => subject_ref,
address => post.address,
message_id => msg_id,
message => post.message,
timestamp => post.timestamp,
datetime => post.datetime,
root_url_prefix => &state.root_url_prefix,
}
})
.collect::<Vec<_>>();
let crumbs = vec![
Crumb {
label: "Lists".into(),
url: "/".into(),
},
Crumb {
label: list.name.clone().into(),
url: format!("/lists/{}/", list.pk).into(),
},
];
let context = minijinja::context! {
title => state.site_title.as_ref(),
page_title => &list.name,
description => &list.description,
post_policy => &post_policy,
subscription_policy => &subscription_policy,
preamble => true,
months => &months,
hists => &hist,
posts => posts_ctx,
body => &list.description.clone().unwrap_or_default(),
root_url_prefix => &state.root_url_prefix,
list => Value::from_object(MailingList::from(list)),
current_user => auth.current_user,
user_context => user_context,
messages => session.drain_messages(),
crumbs => crumbs,
};
Ok(Html(TEMPLATES.get_template("list.html")?.render(context)?))
}
async fn list_edit(Path(_): Path<i64>, State(_): State<Arc<AppState>>) {}
async fn help(
mut session: WritableSession,
auth: AuthContext,
State(state): State<Arc<AppState>>,
) -> Result<Html<String>, ResponseError> {
let crumbs = vec![
Crumb {
label: "Lists".into(),
url: "/".into(),
},
Crumb {
label: "Help".into(),
url: "/help/".into(),
},
];
let context = minijinja::context! {
title => state.site_title.as_ref(),
page_title => "Help & Documentation",
description => "",
root_url_prefix => &state.root_url_prefix,
current_user => auth.current_user,
messages => session.drain_messages(),
crumbs => crumbs,
};
Ok(Html(TEMPLATES.get_template("help.html")?.render(context)?))
}

392
web/src/settings.rs 100644
View File

@ -0,0 +1,392 @@
/*
* 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/>.
*/
use super::*;
use mailpot::models::{
changesets::{AccountChangeset, ListSubscriptionChangeset},
ListSubscription,
};
pub async fn settings(
mut session: WritableSession,
Extension(user): Extension<User>,
state: Arc<AppState>,
) -> Result<Html<String>, ResponseError> {
let root_url_prefix = &state.root_url_prefix;
let crumbs = vec![
Crumb {
label: "Lists".into(),
url: "/".into(),
},
Crumb {
label: "Settings".into(),
url: "/settings/".into(),
},
];
let db = Connection::open_db(state.conf.clone())?;
let acc = db
.account_by_address(&user.address)
.with_status(StatusCode::BAD_REQUEST)?
.ok_or_else(|| {
ResponseError::new("Account not found".to_string(), StatusCode::BAD_REQUEST)
})?;
let subscriptions = db
.account_subscriptions(acc.pk())
.with_status(StatusCode::BAD_REQUEST)?
.into_iter()
.map(|s| {
let list = db.list(s.list)?;
Ok((s, list))
})
.collect::<Result<
Vec<(
DbVal<mailpot::models::ListSubscription>,
DbVal<mailpot::models::MailingList>,
)>,
mailpot::Error,
>>()?;
let context = minijinja::context! {
title => state.site_title.as_ref(),
page_title => "Account settings",
description => "",
root_url_prefix => &root_url_prefix,
user => user,
subscriptions => subscriptions,
current_user => user,
messages => session.drain_messages(),
crumbs => crumbs,
};
Ok(Html(
TEMPLATES.get_template("settings.html")?.render(context)?,
))
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum ChangeSetting {
Subscribe { list_pk: IntPOST },
Unsubscribe { list_pk: IntPOST },
ChangePassword { new: String },
ChangePublicKey { new: String },
// RemovePassword,
RemovePublicKey,
ChangeName { new: String },
}
pub async fn settings_post(
mut session: WritableSession,
Extension(user): Extension<User>,
Form(payload): Form<ChangeSetting>,
state: Arc<AppState>,
) -> Result<Redirect, ResponseError> {
let mut db = Connection::open_db(state.conf.clone())?;
let acc = db
.account_by_address(&user.address)
.with_status(StatusCode::BAD_REQUEST)?
.ok_or_else(|| {
ResponseError::new("Account not found".to_string(), StatusCode::BAD_REQUEST)
})?;
match payload {
ChangeSetting::Subscribe {
list_pk: IntPOST(list_pk),
} => {
let subscriptions = db
.account_subscriptions(acc.pk())
.with_status(StatusCode::BAD_REQUEST)?;
if subscriptions.iter().any(|s| s.list == list_pk) {
session.add_message(Message {
message: "You are already subscribed to this list.".into(),
level: Level::Info,
})?;
} else {
db.add_subscription(
list_pk,
ListSubscription {
pk: 0,
list: list_pk,
account: Some(acc.pk()),
address: acc.address.clone(),
name: acc.name.clone(),
digest: false,
enabled: true,
verified: true,
hide_address: false,
receive_duplicates: false,
receive_own_posts: false,
receive_confirmation: false,
},
)?;
session.add_message(Message {
message: "You have subscribed to this list.".into(),
level: Level::Success,
})?;
}
}
ChangeSetting::Unsubscribe {
list_pk: IntPOST(list_pk),
} => {
let subscriptions = db
.account_subscriptions(acc.pk())
.with_status(StatusCode::BAD_REQUEST)?;
if !subscriptions.iter().any(|s| s.list == list_pk) {
session.add_message(Message {
message: "You are already not subscribed to this list.".into(),
level: Level::Info,
})?;
} else {
let db = db.trusted();
db.remove_subscription(list_pk, &acc.address)?;
session.add_message(Message {
message: "You have unsubscribed from this list.".into(),
level: Level::Success,
})?;
}
}
ChangeSetting::ChangePassword { new } => {
db.update_account(AccountChangeset {
address: acc.address.clone(),
name: None,
public_key: None,
password: Some(new.clone()),
enabled: None,
})
.with_status(StatusCode::BAD_REQUEST)?;
session.add_message(Message {
message: "You have successfully updated your SSH public key.".into(),
level: Level::Success,
})?;
let mut user = user.clone();
user.password = new;
state.insert_user(acc.pk(), user).await;
}
ChangeSetting::ChangePublicKey { new } => {
db.update_account(AccountChangeset {
address: acc.address.clone(),
name: None,
public_key: Some(Some(new.clone())),
password: None,
enabled: None,
})
.with_status(StatusCode::BAD_REQUEST)?;
session.add_message(Message {
message: "You have successfully updated your PGP public key.".into(),
level: Level::Success,
})?;
let mut user = user.clone();
user.public_key = Some(new);
state.insert_user(acc.pk(), user).await;
}
ChangeSetting::RemovePublicKey => {
db.update_account(AccountChangeset {
address: acc.address.clone(),
name: None,
public_key: Some(None),
password: None,
enabled: None,
})
.with_status(StatusCode::BAD_REQUEST)?;
session.add_message(Message {
message: "You have successfully removed your PGP public key.".into(),
level: Level::Success,
})?;
let mut user = user.clone();
user.public_key = None;
state.insert_user(acc.pk(), user).await;
}
ChangeSetting::ChangeName { new } => {
let new = if new.trim().is_empty() {
None
} else {
Some(new)
};
db.update_account(AccountChangeset {
address: acc.address.clone(),
name: Some(new.clone()),
public_key: None,
password: None,
enabled: None,
})
.with_status(StatusCode::BAD_REQUEST)?;
session.add_message(Message {
message: "You have successfully updated your name.".into(),
level: Level::Success,
})?;
let mut user = user.clone();
user.name = new.clone();
state.insert_user(acc.pk(), user).await;
}
}
Ok(Redirect::to(&format!(
"{}/settings/",
&state.root_url_prefix
)))
}
pub async fn user_list_subscription(
mut session: WritableSession,
Extension(user): Extension<User>,
Path(id): Path<i64>,
State(state): State<Arc<AppState>>,
) -> Result<Html<String>, ResponseError> {
let root_url_prefix = &state.root_url_prefix;
let db = Connection::open_db(state.conf.clone())?;
let crumbs = vec![
Crumb {
label: "Lists".into(),
url: "/".into(),
},
Crumb {
label: "Settings".into(),
url: "/settings/".into(),
},
Crumb {
label: "List Subscription".into(),
url: format!("/settings/list/{}/", id).into(),
},
];
let list = db.list(id)?;
let acc = match db.account_by_address(&user.address)? {
Some(v) => v,
None => {
return Err(ResponseError::new(
"Account not found".to_string(),
StatusCode::BAD_REQUEST,
))
}
};
let mut subscriptions = db
.account_subscriptions(acc.pk())
.with_status(StatusCode::BAD_REQUEST)?;
subscriptions.retain(|s| s.list == id);
let subscription = db
.list_subscription(
id,
subscriptions
.get(0)
.ok_or_else(|| {
ResponseError::new(
"Subscription not found".to_string(),
StatusCode::BAD_REQUEST,
)
})?
.pk(),
)
.with_status(StatusCode::BAD_REQUEST)?;
let context = minijinja::context! {
title => state.site_title.as_ref(),
page_title => "Subscription settings",
description => "",
root_url_prefix => &root_url_prefix,
user => user,
list => list,
subscription => subscription,
current_user => user,
messages => session.drain_messages(),
crumbs => crumbs,
};
Ok(Html(
TEMPLATES
.get_template("settings_subscription.html")?
.render(context)?,
))
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Default)]
pub struct SubscriptionFormPayload {
#[serde(default)]
pub digest: bool,
#[serde(default)]
pub hide_address: bool,
#[serde(default)]
pub receive_duplicates: bool,
#[serde(default)]
pub receive_own_posts: bool,
#[serde(default)]
pub receive_confirmation: bool,
}
pub async fn user_list_subscription_post(
mut session: WritableSession,
Path(id): Path<i64>,
Extension(user): Extension<User>,
Form(payload): Form<SubscriptionFormPayload>,
state: Arc<AppState>,
) -> Result<Redirect, ResponseError> {
let mut db = Connection::open_db(state.conf.clone())?;
let _list = db.list(id).with_status(StatusCode::NOT_FOUND)?;
let acc = match db.account_by_address(&user.address)? {
Some(v) => v,
None => {
return Err(ResponseError::new(
"Account with this address was not found".to_string(),
StatusCode::BAD_REQUEST,
));
}
};
let mut subscriptions = db
.account_subscriptions(acc.pk())
.with_status(StatusCode::BAD_REQUEST)?;
subscriptions.retain(|s| s.list == id);
let mut s = db
.list_subscription(id, subscriptions[0].pk())
.with_status(StatusCode::BAD_REQUEST)?;
let SubscriptionFormPayload {
digest,
hide_address,
receive_duplicates,
receive_own_posts,
receive_confirmation,
} = payload;
let cset = ListSubscriptionChangeset {
list: s.list,
address: std::mem::take(&mut s.address),
account: None,
name: None,
digest: Some(digest),
hide_address: Some(hide_address),
receive_duplicates: Some(receive_duplicates),
receive_own_posts: Some(receive_own_posts),
receive_confirmation: Some(receive_confirmation),
enabled: None,
verified: None,
};
db.update_subscription(cset)
.with_status(StatusCode::BAD_REQUEST)?;
session.add_message(Message {
message: "Settings saved successfully.".into(),
level: Level::Success,
})?;
Ok(Redirect::to(&format!(
"{}/settings/list/{id}/",
&state.root_url_prefix
)))
}

View File

@ -0,0 +1,15 @@
{% include "header.html" %}
<div class="body body-grid">
<p>Sign <mark><code>{{ ssh_challenge }}</code></mark> with your previously configured key within <time title="{{ timeout_left }}" datetime="{{ timeout_left }}">{{ timeout_left }} minutes</time>. Example:</p>
<pre class="command-line-example">printf '<ruby><mark>{{ ssh_challenge }}</mark><rp>(</rp><rt>signin challenge</rt><rp>)</rp></ruby>' | ssh-keygen -Y sign -f <ruby>~/.ssh/id_rsa <rp>(</rp><rt>your account's key</rt><rp>)</rp></ruby> -n <ruby>{{ namespace }}<rp>(</rp><rt>namespace</rt><rp>)</rp></ruby></pre>
<form method="post" class="login-form login-ssh">
<label for="id_address">Email address:</label>
<input type="text" name="address" required="" id="id_address">
<label for="id_password">SSH signature:</label>
<textarea class="key-or-sig-input" name="password" cols="15" rows="5" placeholder="-----BEGIN SSH SIGNATURE-----&#10;changemechangemechangemechangemechangemechangemechangemechangemechange&#10;mechangemechangemechangemechangemechangemechangemechangemechangemechan&#10;gemechangemechangemechangemechangemechangemechangemechangemechangemech&#10;angemechangemechangemechangemechangemechangemechangemechangemechangeme&#10;changemechangemechangemechangemechangemechangemechangemechangemechange&#10;mechangemechangemechangemechangemechangemechangemechangemechangemechan&#10;gemechangemechangemechangemechangemechangemechangemechangemechangemech&#10;angemechangemechangemechangemechangemechangemechangemechangemechangeme&#10;changemechangemechangemechangemechangemechangemechangemechangemechange&#10;mechangemechangemechangemechangemechangemechangemechangemechangemechan&#10;gemechangemechangemechangemechangemechangemechangemechangemechangemech&#10;angemechangemechangemechangemechangemechangemechangemechangemechangeme&#10;changemechangemechangemechangemechangemechangemechangemechangemechange&#10;mechangemechangemechangemechangemechangemechangemechangemechangemechan&#10;gemechangemechangemechangemechangemechangemechangemechangemechangemech&#10;angemechangemechangemechangemechangemechangemechangemechangemechangeme&#10;changemechangemechangemechangemechangemechangemechangemechangemechange&#10;mechangemechangemechangemechangemechangemechangemechangemechangemechan&#10;gemechangemechangemechangemechangemechangemechangemechangemechangemech&#10;angemechangemechangemechangemechangemechangemechangemechangemechangeme&#10;changemechangemechangemechangemechangemechangemechangemechangemechange&#10;chang=&#10;-----END SSH SIGNATURE-----&#10;" required="" id="id_password"></textarea>
<input type="submit" value="login">
<input type="hidden" name="next" value="">
<input formaction="/accounts/sshlogin/" formnovalidate="true" type="submit" name="refresh" value="refresh token">
</form>
</div>
{% include "footer.html" %}

View File

@ -0,0 +1,43 @@
{% macro cal(date, hists, root_url_prefix, pk) %}
{% set c=calendarize(date, hists) %}
{% if c.sum > 0 %}
<table>
<caption align="top">
<!--<a href="{{ root_url_prefix|safe }}/list/{{pk}}/{{ c.month }}">-->
<a href="#" style="color: GrayText;">
{{ c.month_name }} {{ c.year }}
</a>
</caption>
<thead>
<tr>
<th>M</th>
<th>Tu</th>
<th>W</th>
<th>Th</th>
<th>F</th>
<th>Sa</th>
<th>Su</th>
</tr>
</thead>
<tbody>
{% for week in c.weeks %}
<tr>
{% for day in week %}
{% if day == 0 %}
<td></td>
{% else %}
{% set num = c.hist[day-1] %}
{% if num > 0 %}
<td><ruby>{{ day }}<rt>({{ num }})</rt></ruby></td>
{% else %}
<td class="empty">{{ day }}</td>
{% endif %}
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endmacro %}
{% set alias = cal %}

View File

@ -0,0 +1,721 @@
<style>
@charset "UTF-8";
* Use a more intuitive box-sizing model */
*, *::before, *::after {
box-sizing: border-box;
}
/* Remove all margins & padding */
* {
margin: 0;
padding: 0;
word-wrap: break-word;
}
/* Only show focus outline when the user is tabbing (not when clicking) */
*:focus {
outline: none;
}
*:focus-visible {
outline: 1px solid blue;
}
/* Prevent mobile browsers increasing font-size */
html {
-moz-text-size-adjust: none;
-webkit-text-size-adjust: none;
text-size-adjust: none;
font-family:-apple-system,BlinkMacSystemFont,Arial,sans-serif;
line-height:1.15;
-webkit-text-size-adjust:100%;
overflow-y:scroll;
}
/* Allow percentage-based heights */
/* Setting width: 100% isn't required because it is a default for block-level elements (html & body are block level) */
html, body {
height: 100%;
}
body {
/* Prevent the rubber band effect when the user scrolls to the top or bottom of the page (WebKit only) */
overscroll-behavior: none;
/* Prevent the browser from synthesizing missing typefaces */
font-synthesis: none;
color: black;
/* UI controls color (example: range input) */
accent-color: black;
/* Because overscroll-behavior: none only works on WebKit, a background color is set that will show when overscroll occurs */
background: white;
margin:0;
font-feature-settings:"onum" 1;
text-rendering:optimizeLegibility;
-webkit-font-smoothing:antialiased;
-moz-osx-font-smoothing:grayscale;
font-family:-apple-system,BlinkMacSystemFont,Roboto,Roboto Slab,Droid Serif,Segoe UI,system-ui,Arial,sans-serif;
font-size:1.125em
}
/* Remove unintuitive behaviour such as gaps around media elements. */
img, picture, video, canvas, svg, iframe {
display: block;
}
/* Avoid text overflow */
h1, h2, h3, h4, h5, h6, p, strong {
overflow-wrap: break-word;
}
a {
text-decoration: none;
}
ul, ol {
list-style: none;
}
code {
overflow-wrap: anywhere;
}
input {
border: none;
}
input, button, textarea, select {
font: inherit;
}
/* Create a root stacking context (only when using frameworks like Next.js) */
#__next {
isolation: isolate;
}
@media (prefers-color-scheme: light) {
:root {
--text-primary: #1b1b1b;
--text-secondary: #4e4e4e;
--text-inactive: #9e9e9ea6;
--text-link: #0069c2;
--text-invert: #fff;
--background-primary: #fff;
--background-secondary: #f9f9fb;
--background-tertiary: #fff;
--background-toc-active: #ebeaea;
--background-mark-yellow: #c7b70066;
--background-mark-green: #00d06166;
--background-information: #0085f21a;
--background-warning: #ff2a511a;
--background-critical: #d300381a;
--background-success: #0079361a;
--border-primary: #cdcdcd;
--border-secondary: #cdcdcd;
--button-primary-default: #1b1b1b;
--button-primary-hover: #696969;
--button-primary-active: #9e9e9e;
--button-primary-inactive: #1b1b1b;
--button-secondary-default: #fff;
--button-secondary-hover: #cdcdcd;
--button-secondary-active: #cdcdcd;
--button-secondary-inactive: #f9f9fb;
--button-secondary-border-focus: #0085f2;
--button-secondary-border-red: #ff97a0;
--button-secondary-border-red-focus: #ffd9dc;
--icon-primary: #696969;
--icon-secondary: #b3b3b3;
--icon-information: #0085f2;
--icon-warning: #ff2a51;
--icon-critical: #d30038;
--icon-success: #007936;
--accent-primary: #0085f2;
--accent-primary-engage: #0085f21a;
--accent-secondary: #0085f2;
--accent-tertiary: #0085f21a;
--shadow-01: 0 1px 2px rgba(43,42,51,.05);
--shadow-02: 0 1px 6px rgba(43,42,51,.1);
--focus-01: 0 0 0 3px rgba(0,144,237,.4);
--field-focus-border: #0085f2;
--code-token-tag: #0069c2;
--code-token-punctuation: #858585;
--code-token-attribute-name: #d30038;
--code-token-attribute-value: #007936;
--code-token-comment: #858585;
--code-token-default: #1b1b1b;
--code-token-selector: #872bff;
--code-background-inline: #f2f1f1;
--code-background-block: #f2f1f1;
--notecard-link-color: #343434;
--scrollbar-bg: transparent;
--scrollbar-color: #00000040;
--category-color: #0085f2;
--category-color-background: #0085f210;
--code-color: #5e9eff;
--mark-color: #dce2f2;
--blend-color: #fff80;
--text-primary-red: #d30038;
--text-primary-green: #007936;
--text-primary-blue: #0069c2;
--text-primary-yellow: #746a00;
--form-invalid-color: #d30038;
--form-invalid-focus-color: #ff2a51;
--form-invalid-focus-effect-color: #ff2a5133;
color-scheme: light;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text-primary: #fff;
--text-secondary: #cdcdcd;
--text-inactive: #cdcdcda6;
--text-link: #8cb4ff;
--text-invert: #1b1b1b;
--background-primary: #1b1b1b;
--background-secondary: #343434;
--background-tertiary: #4e4e4e;
--background-toc-active: #343434;
--background-mark-yellow: #c7b70066;
--background-mark-green: #00d06166;
--background-information: #0085f21a;
--background-warning: #ff2a511a;
--background-critical: #d300381a;
--background-success: #0079361a;
--border-primary: #858585;
--border-secondary: #696969;
--button-primary-default: #fff;
--button-primary-hover: #cdcdcd;
--button-primary-active: #9e9e9e;
--button-primary-inactive: #fff;
--button-secondary-default: #4e4e4e;
--button-secondary-hover: #858585;
--button-secondary-active: #9e9e9e;
--button-secondary-inactive: #4e4e4e;
--button-secondary-border-focus: #0085f2;
--button-secondary-border-red: #ff97a0;
--button-secondary-border-red-focus: #ffd9dc;
--icon-primary: #fff;
--icon-secondary: #b3b3b3;
--icon-information: #5e9eff;
--icon-warning: #afa100;
--icon-critical: #ff707f;
--icon-success: #00b755;
--accent-primary: #5e9eff;
--accent-primary-engage: #5e9eff1a;
--accent-secondary: #5e9eff;
--accent-tertiary: #0085f21a;
--shadow-01: 0 1px 2px rgba(251,251,254,.2);
--shadow-02: 0 1px 6px rgba(251,251,254,.2);
--focus-01: 0 0 0 3px rgba(251,251,254,.5);
--field-focus-border: #fff;
--notecard-link-color: #e2e2e2;
--scrollbar-bg: transparent;
--scrollbar-color: #ffffff40;
--category-color: #8cb4ff;
--category-color-background: #8cb4ff70;
--code-color: #c1cff1;
--mark-color: #004d92;
--blend-color: #00080;
--text-primary-red: #ff97a0;
--text-primary-green: #00d061;
--text-primary-blue: #8cb4ff;
--text-primary-yellow: #c7b700;
--collections-link: #ff97a0;
--collections-header: #40000a;
--collections-mandala: #9e0027;
--collections-icon: #d30038;
--updates-link: #8cb4ff;
--updates-header: #000;
--updates-mandala: #c1cff1;
--updates-icon: #8cb4ff;
--form-limit-color: #9e9e9e;
--form-limit-color-emphasis: #b3b3b3;
--form-invalid-color: #ff97a0;
--form-invalid-focus-color: #ff707f;
--form-invalid-focus-effect-color: #ff707f33;
color-scheme: dark;
}
}
body>main.layout {
width: 100%;
height: 99%;
overflow-wrap: anywhere;
display: grid;
grid:
"header header header" auto
"leftside body rightside" 1fr
"footer footer footer" auto
/ auto 1fr auto;
gap: 8px;
}
main.layout>.header { grid-area: header; }
main.layout>.leftside { grid-area: leftside; }
main.layout>div.body { grid-area: body; }
main.layout>.rightside { grid-area: rightside; }
main.layout>footer {
grid-area: footer;
border-top: 2px inset;
margin-block-start: 1rem;
}
main.layout>footer>* {
margin-block-start: 1rem;
margin-inline-start: 1rem;
margin-block-end: 1rem;
}
main.layout>div.header>h1 {
margin: 1rem;
}
main.layout>div.header>h2 {
margin-inline-start: 1rem;
}
main.layout>div.header>nav + nav {
margin-top: 1rem;
}
nav.main-nav {
padding: 0rem 1rem;
border: 1px solid #4d4e53;
border-radius: 2px;
padding: 10px 14px 10px 10px;
margin-bottom: 10px;
}
nav.main-nav>ul {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
nav.main-nav>ul>li>a {
padding: 1rem;
}
nav.main-nav > ul > li > a:hover {
outline: 0.1rem solid;
outline-offset: -0.5rem;
}
nav.main-nav >ul .push {
margin-left: auto;
}
main.layout>div.body h2 {
margin: 1rem;
}
nav.breadcrumbs {
padding: 10px 14px 10px 0px;
margin-inline-start: 1rem;
}
nav.breadcrumbs ol {
list-style-type: none;
padding-left: 0;
}
.crumb, .crumb>a {
display: contents;
}
.crumb a::after {
display: inline-block;
color: #000;
content: '>';
font-size: 80%;
font-weight: bold;
padding: 0 3px;
}
.crumb span[aria-current="page"] {
color: GrayText;
padding: 0.4rem;
margin-left: -0.4rem;
display: contents;
}
ul.messagelist {
list-style-type: none;
margin: 0;
padding: 0;
background: var(--background-secondary);
}
ul.messagelist:not(:empty) {
margin-block-end: 0.5rem;
}
ul.messagelist>li {
padding: 0.5rem 1rem;
background: var(--message-background);
border: .1rem solid var(--border-secondary);
border-radius: 0.2rem;
font-weight: medium;
margin-block-end: 1.0rem;
}
ul.messagelist>li>span.label {
text-transform: capitalize;
font-weight: bolder;
}
ul.messagelist>li.error {
--message-background: var(--background-critical);
}
ul.messagelist>li.success {
--message-background: var(--background-success);
}
ul.messagelist>li.warning {
--message-background: var(--background-warning);
}
ul.messagelist>li.info {
--message-background: var(--background-information);
}
div.preamble {
display: flex;
flex-direction: column;
gap: 1rem;
}
div.calendar th {
padding: 0.5rem;
opacity: 0.7;
}
div.calendar tr,
div.calendar th {
text-align: right;
font-variant-numeric: tabular-nums;
font-family: monospace;
}
div.calendar table {
display: inline-table;
border-collapse: collapse;
}
div.calendar td {
padding: 0.1rem 0.4rem;
}
div.calendar td.empty {
color: GrayText;
}
div.calendar td:not(.empty) {
font-weight: bold;
}
div.calendar td:not(:empty) {
border: 1px solid black;
}
div.calendar td:empty {
background: GrayText;
opacity: 0.3;
}
div.calendar {
display: flex;
flex-wrap: wrap;
flex-direction: row;
gap: 1rem;
align-items: baseline;
}
div.calendar caption {
font-weight: bold;
}
div.entries {
display: flex;
flex-direction: column;
gap: 1rem;
}
div.entries>div.entry {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
div.posts>div.entry>span.subject {
font-size: larger;
}
div.entries>div.entry>span.metadata {
color: GrayText;
word-break: break-all;
}
div.entries>div.entry span.value {
max-width: 44ch;
display: inline-block;
white-space: break-spaces;
word-wrap: anywhere;
word-break: break-all;
vertical-align: top;
}
div.entries>div.entry span.value.empty {
color: GrayText;
}
div.posts>div.entry>span.metadata>span.from {
margin-inline-end: 1rem;
}
table.headers tr>th {
text-align: right;
color: GrayText;
}
table.headers th[scope="row"] {
padding-right: .5rem;
}
table.headers tr>th:after {
content:':';
display: inline-block;
}
div.post-body {
margin: 1rem;
}
div.post-body>pre {
max-width: 98vw;
overflow-wrap: break-word;
white-space: pre-line;
}
td.message-id,
span.message-id{
color: GrayText;
}
td.message-id:before,
span.message-id:before{
content:'<';
display: inline-block;
}
td.message-id:after,
span.message-id:after{
content:'>';
display: inline-block;
}
span.message-id + span.message-id:before{
content:', <';
display: inline-block;
}
td.faded,
span.faded {
color: GrayText;
}
td.faded:is(:focus, :hover, :focus-visible, :focus-within),
span.faded:is(:focus, :hover, :focus-visible, :focus-within) {
color: revert;
}
ul.lists {
padding: 1rem 2rem;
}
ul.lists li {
list-style: disc;
}
ul.lists li + li {
margin-top: 1rem;
}
hr {
margin: 1rem 0rem;
}
.command-line-example {
user-select: all;
display: inline-block;
ruby-align: center;
ruby-position: under;
padding: 0;
--pre-bg-color: #e9e9e9;
background: var(--pre-bg-color);
outline: 5px solid var(--pre-bg-color);
width: min-content;
max-width: 90vw;
padding: 2px 7px;
overflow-wrap: break-word;
overflow: auto;
white-space: pre;
}
textarea.key-or-sig-input {
font-family: monospace;
font-size: 0.5rem;
font-weight: medium;
width: auto;
height: 29rem;
max-width: min(71ch, 75vw);
overflow-wrap: break-word;
overflow: auto;
white-space: pre;
line-height: 1rem;
vertical-align: top;
}
textarea.key-or-sig-input.wrap {
word-wrap: anywhere;
word-break: break-all;
white-space: break-spaces;
}
.login-ssh textarea#id_password::placeholder {
line-height: 1rem;
}
.body-grid {
display: grid;
grid-template-columns: 1fr;
grid-auto-rows: min-content;
row-gap: min(6vw, 1rem);
width: 100%;
height: 100%;
}
form.login-form {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 98vw;
width: auto;
}
form.login-form > :not([type="hidden"]) + label, fieldset > :not([type="hidden"], legend) + label {
margin-top: 1rem;
}
form.settings-form {
display: grid;
grid-template-columns: auto;
gap: 1rem;
max-width: 90vw;
width: auto;
overflow: auto;
}
form.settings-form>input[type="submit"] {
place-self: start;
}
form.settings-form>fieldset {
padding: 1rem 1.5rem 2rem 1.5rem;
}
form.settings-form>fieldset>legend {
padding: .5rem 1rem;
border: 1px ridge lightgray;
font-weight: bold;
font-size: small;
margin-left: 0.8rem;
}
form.settings-form>fieldset>div {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
}
form.settings-form>fieldset>div>label:last-child {
padding: 1rem 0 1rem 1rem;
flex-grow: 2;
max-width: max-content;
}
form.settings-form>fieldset>div>label:first-child {
padding: 1rem 1rem 1rem 0rem;
flex-grow: 2;
max-width: max-content;
}
form.settings-form>fieldset>div>:not(label):not(input) {
flex-grow: 8;
width: auto;
}
form.settings-form>fieldset>div>input {
margin: 0.8rem;
}
button, input {
overflow: visible;
}
button, input, optgroup, select, textarea {
font-family: inherit;
font-size: 100%;
line-height: 1.15;
margin: 0;
}
form label {
font-weight: 500;
}
textarea {
max-width: var(--main-width);
width: 100%;
resize: both;
}
textarea {
overflow: auto;
}
button, [type="button"], [type="reset"], [type="submit"] {
-webkit-appearance: button;
}
input, textarea {
display: inline-block;
appearance: auto;
-moz-default-appearance: textfield;
padding: 1px;
border: 2px inset ButtonBorder;
border-radius: 5px;
padding: .5rem;
background-color: Field;
color: FieldText;
font: -moz-field;
text-rendering: optimizeLegibility;
cursor: text;
}
button, ::file-selector-button, input:is([type="color"], [type="reset"], [type="button"], [type="submit"]) {
appearance: auto;
-moz-default-appearance: button;
padding-block: 1px;
padding-inline: 8px;
border: 2px outset ButtonBorder;
border-radius: 3px;
background-color: ButtonFace;
cursor: default;
box-sizing: border-box;
user-select: none;
padding: .5rem;
min-width: 10rem;
align-self: start;
}
ol.list {
list-style: decimal outside;
padding-inline-start: 4rem;
}
</style>

View File

@ -0,0 +1,6 @@
<footer>
<p>Generated by <a href="https://github.com/meli/mailpot">mailpot</a>.</p>
</footer>
</main>
</body>
</html>

View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title>
{% include "css.html" %}
</head>
<body>
<main class="layout">
<div class="header">
<h1>{{ title }}</h1>
{% if description %}
<p class="description">{{ description }}</p>
{% endif %}
{% include "menu.html" %}
{% if messages %}
<ul class="messagelist">
{% for message in messages %}
<li class="{{ message.level|lower }}">
<span class="label">{{ message.level }}: </span>{{ message.message }}
</li>
{% endfor %}
</ul>
{% endif %}
</div>

View File

@ -0,0 +1,5 @@
{% include "header.html" %}
<div class="body body-grid">
{{ body }}
</div>
{% include "footer.html" %}

View File

@ -0,0 +1,11 @@
{% include "header.html" %}
<div class="entry">
<div class="body">
<ul>
{% for l in lists %}
<li><a href="{{ root_url_prefix|safe }}/lists/{{ l.list.pk }}/">{{ l.list.name }}</a></li>
{% endfor %}
</ul>
</div>
</div>
{% include "footer.html" %}

View File

@ -0,0 +1,102 @@
{% include "header.html" %}
<div class="body">
<p>List Description: {{ list.description if list.description else "None" }}</p>
<br />
{% if current_user and not post_policy.no_subscriptions and subscription_policy.open %}
{% if user_context %}
<form method="post" action="{{ root_url_prefix }}/settings/" class="settings-form">
<input type="hidden" name="type", value="unsubscribe">
<input type="hidden" name="list_pk", value="{{ list.pk }}">
<input type="submit" name="unsubscribe" value="Unsubscribe as {{ current_user.address }}">
</form>
{% else %}
<form method="post" action="{{ root_url_prefix }}/settings/" class="settings-form">
<input type="hidden" name="type", value="subscribe">
<input type="hidden" name="list_pk", value="{{ list.pk }}">
<input type="submit" name="subscribe" value="Subscribe as {{ current_user.address }}">
</form>
{% endif %}
{% endif %}
{% if preamble %}
<hr />
<div id="preamble" class="preamble">
{% if preamble.custom %}
{{ preamble.custom|safe }}
{% else %}
{% if not post_policy.no_subscriptions %}
<h3 id="subscribe">Subscribe</h3>
{% set subscription_mailto=list.subscription_mailto() %}
{% if subscription_mailto %}
{% if subscription_mailto.subject %}
<p>
<a href="mailto:{{ subscription_mailto.address|safe }}?subject={{ subscription_mailto.subject|safe }}"><code>{{ subscription_mailto.address }}</code></a> with the following subject: <code>{{ subscription_mailto.subject}}</code>
</p>
{% else %}
<p>
<a href="mailto:{{ subscription_mailto.address|safe }}"><code>{{ subscription_mailto.address }}</code></a>
</p>
{% endif %}
{% else %}
<p>List is not open for subscriptions.</p>
{% endif %}
{% set unsubscription_mailto=list.unsubscription_mailto() %}
{% if unsubscription_mailto %}
<h3 id="unsubscribe">Unsubscribe</h3>
{% if unsubscription_mailto.subject %}
<p>
<a href="mailto:{{ unsubscription_mailto.address|safe }}?subject={{ unsubscription_mailto.subject|safe }}"><code>{{ unsubscription_mailto.address }}</code></a> with the following subject: <code>{{unsubscription_mailto.subject}}</code>
</p>
{% else %}
<p>
<a href="mailto:{{ unsubscription_mailto.address|safe }}"><code>{{ unsubscription_mailto.address }}</code></a>
</p>
{% endif %}
{% endif %}
{% endif %}
<h3 id="post">Post</h3>
{% if post_policy.announce_only %}
<p>List is <em>announce-only</em>, i.e. you can only subscribe to receive announcements.</p>
{% elif post_policy.subscription_only %}
<p>List is <em>subscription-only</em>, i.e. you can only post if you are subscribed.</p>
<p>If you are subscribed, you can send new posts to:
<a href="mailto:{{ list.address| safe }}"><code>{{ list.address }}</code></a>
</p>
{% elif post_policy.approval_needed or post_policy.no_subscriptions %}
<p>List is open to all posts <em>after approval</em> by the list owners.</p>
<p>You can send new posts to:
<a href="mailto:{{ list.address| safe }}"><code>{{ list.address }}</code></a>
</p>
{% else %}
<p>List is not open for submissions.</p>
{% endif %}
{% endif %}
</div>
<hr />
{% else %}
<hr />
{% endif %}
<div class="list">
<h3 id="calendar">Calendar</h3>
<div class="calendar">
{%- from "calendar.html" import cal %}
{% for date in months %}
{{ cal(date, hists, root_url_prefix, list.pk) }}
{% endfor %}
</div>
<hr />
<h3 id="posts">Posts</h3>
<div class="posts entries">
<p>{{ posts | length }} post(s)</p>
{% for post in posts %}
<div class="entry">
<span class="subject"><a href="{{ root_url_prefix|safe }}/list/{{post.list}}/{{ post.message_id }}.html">{{ 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>
{% endfor %}
</div>
</div>
</div>
{% include "footer.html" %}

View File

@ -0,0 +1,12 @@
{% include "header.html" %}
<div class="body">
<p>{{ lists|length }} lists</p>
<div class="entry">
<ul class="lists">
{% for l in lists %}
<li><a href="{{ root_url_prefix|safe }}/lists/{{ l.list.pk }}/">{{ l.list.name }}</a></li>
{% endfor %}
</ul>
</div>
</div>
{% include "footer.html" %}

View File

@ -0,0 +1,17 @@
<nav class="main-nav">
<ul>
<li><a href="{{ root_url_prefix }}/">Index</a></li>
<li><a href="{{ root_url_prefix }}/help/">Help&nbsp;&amp; Documentation</a></li>
{% if current_user %}
<li class="push">Settings: <a href="{{ root_url_prefix }}/settings/">{{ current_user.address }}</a></li>
{% else %}
<li class="push"><a href="{{ root_url_prefix }}/login/">Login with SSH OTP</a></li>
{% endif %}
</ul>
</nav>
{% if page_title %}
<h2>{{ page_title }}</h2>
{% endif %}
<nav aria-label="Breadcrumb" class="breadcrumbs">
<ol>{% for crumb in crumbs %}{% if loop.last %}<li class="crumb"><span aria-current="page">{{ crumb.label }}</span></li>{% else %}<li class="crumb"><a href="{{ root_url_prefix }}{{ crumb.url }}">{{ crumb.label }}</a></li>{% endif %}{% endfor %}</ol>
</nav>

View File

@ -0,0 +1,42 @@
{% include "header.html" %}
<div class="body">
<h3>{{ trimmed_subject }}</h3>
<table class="headers">
<tr>
<th scope="row">List</th>
<td class="faded">{{ list.id }}</td>
</tr>
<tr>
<th scope="row">From</th>
<td>{{ from }}</td>
</tr>
<tr>
<th scope="row">To</th>
<td class="faded">{{ to }}</td>
</tr>
<tr>
<th scope="row">Subject</th>
<td>{{ subject }}</td>
</tr>
<tr>
<th scope="row">Date</th>
<td class="faded">{{ date }}</td>
</tr>
{% if in_reply_to %}
<tr>
<th scope="row">In-Reply-To</th>
<td class="faded message-id"><a href="{{ root_url_prefix|safe }}/list/{{ list.pk }}/{{ in_reply_to }}.html">{{ in_reply_to }}</a></td>
</tr>
{% endif %}
{% if references %}
<tr>
<th scope="row">References</th>
<td>{% for r in references %}<span class="faded message-id"><a href="{{ root_url_prefix|safe }}/list/{{ list.pk }}/{{ r }}.html">{{ r }}</a></span>{% endfor %}</td>
</tr>
{% endif %}
</table>
<div class="post-body">
<pre>{{ body }}</pre>
</div>
</div>
{% include "footer.html" %}

View File

@ -0,0 +1,83 @@
{% include "header.html" %}
<div class="body body-grid">
<h3>Your account</h3>
<div class="entries">
<div class="entry">
<span>Display name: <span class="value{% if not user.name %} empty{% endif %}">{{ user.name if user.name else "None" }}</span></span>
</div>
<div class="entry">
<span>Address: <span class="value">{{ user.address }}</span></span>
</div>
<div class="entry">
<span>PGP public key: <span class="value{% if not user.public_key %} empty{% endif %}">{{ user.public_key if user.public_key else "None." }}</span></span>
</div>
<div class="entry">
<span>SSH public key: <span class="value{% if not user.password %} empty{% endif %}">{{ user.password if user.password else "None." }}</span></span>
</div>
</div>
<h4>List Subscriptions</h4>
<div class="entries">
<p>{{ subscriptions | length }} subscription(s)</p>
{% for (s, list) in subscriptions %}
<div class="entry">
<span class="subject"><a href="{{ root_url_prefix }}/settings/list/{{ s.list }}/">{{ list.name }}</a></span>
<!-- span class="metadata">📆&nbsp;<span>{{ s.created }}</span></span -->
</div>
{% endfor %}
</div>
<h4>Account Settings</h4>
<form method="post" action="{{ root_url_prefix }}/settings/" class="settings-form">
<input type="hidden" name="type", value="change-name">
<fieldset>
<legend>Change display name</legend>
<div>
<label for="id_name">New name:</label>
<input type="text" name="new" id="id_name" value="{{ user.name if user.name else "" }}">
</div>
</fieldset>
<input type="submit" name="change" value="Change">
</form>
<form method="post" action="{{ root_url_prefix }}/settings/" class="settings-form">
<input type="hidden" name="type", value="change-password">
<fieldset>
<legend>Change SSH public key</legend>
<div>
<label for="id_ssh_public_key">New SSH public key:</label>
<textarea class="key-or-sig-input wrap" required="" cols="15" rows="5" name="new" id="id_ssh_public_key">{{ user.password if user.password else "" }}</textarea>
</div>
</fieldset>
<input type="submit" name="change" value="Change">
</form>
<form method="post" action="{{ root_url_prefix }}/settings/" class="settings-form">
<input type="hidden" name="type", value="change-public-key">
<fieldset>
<legend>Change PGP public key</legend>
<div>
<label for="id_public_key">New PGP public key:</label>
<textarea class="key-or-sig-input wrap" required="" cols="15" rows="5" name="new" id="id_public_key">{{ user.public_key if user.public_key else "" }}</textarea>
</div>
</fieldset>
<input type="submit" name="change-public-key" value="Change">
</form>
<form method="post" action="{{ root_url_prefix }}/settings/" class="settings-form">
<input type="hidden" name="type", value="remove-public-key">
<fieldset>
<legend>Remove PGP public key</legend>
<div>
<input type="checkbox" required="" name="remove-public-keyim-sure" id="remove-public-key-im-sure">
<label for="remove-public-key-im-sure">I am certain I want to remove my PGP public key.</label>
</div>
</fieldset>
<input type="submit" name="remove-public-key" value="Remove">
</form>
</div>
{% include "footer.html" %}

View File

@ -0,0 +1,57 @@
{% include "header.html" %}
<div class="body body-grid">
<h3>Your subscription to <a href="{{ root_url_prefix|safe }}/lists/{{ list.pk }}/">{{ list.id }}</a>.</h3>
<address>
{{ list.name }} <a href="mailto:{{ list.address | safe }}"><code>{{ list.address }}</code></a>
</address>
{% if list.description %}
<p>{{ list.description }}</p>
{% endif %}
{% if list.archive_url %}
<p><a href="{{ list.archive_url }}">{{ list.archive_url }}</a></p>
{% endif %}
<form method="post" class="settings-form">
<fieldset>
<legend>subscription settings</legend>
<div>
<input type="checkbox" value="true" name="digest" id="id_digest"{% if subscription.digest %} checked{% endif %}>
<label for="id_digest">Receive posts as a digest.</label>
</div>
<div>
<input type="checkbox" value="true" name="hide_address" id="id_hide_address"{% if subscription.hide_address %} checked{% endif %}>
<label for="id_hide_address">Hide your e-mail address in your posts.</label>
</div>
<div>
<input type="checkbox" value="true" name="receive_duplicates" id="id_receive_duplicates"{% if subscription.receive_duplicates %} checked{% endif %}>
<label for="id_receive_duplicates">Receive mailing list post duplicates, <abbr title="that is">i.e.</abbr> posts addressed both to you and the mailing list to which you are subscribed.</label>
</div>
<div>
<input type="checkbox" value="true" name="receive_own_posts" id="id_receive_own_posts"{% if subscription.receive_own_posts %} checked{% endif %}>
<label for="id_receive_own_posts">Receive your own mailing list posts from the mailing list.</label>
</div>
<div>
<input type="checkbox" value="true" name="receive_confirmation" id="id_receive_confirmation"{% if subscription.receive_confirmation %} checked{% endif %}>
<label for="id_receive_confirmation">Receive a plain confirmation for your own mailing list posts.</label>
</div>
</fieldset>
<input type="submit" value="Update settings">
<input type="hidden" name="next" value="">
</form>
<form method="post" action="{{ root_url_prefix }}/settings/" class="settings-form">
<fieldset>
<input type="hidden" name="type", value="unsubscribe">
<input type="hidden" name="list_pk", value="{{ list.pk }}">
<legend>Unsubscribe</legend>
<input type="checkbox" required="" name="im-sure" id="unsubscribe-im-sure">
<label for="unsubscribe-im-sure">I am certain I want to unsubscribe.</label>
</fieldset>
<input type="submit" name="subscribe" value="Unsubscribe">
</form>
</div>
{% include "footer.html" %}

278
web/src/utils.rs 100644
View File

@ -0,0 +1,278 @@
/*
* 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/>.
*/
use super::*;
lazy_static::lazy_static! {
pub static ref TEMPLATES: Environment<'static> = {
let mut env = Environment::new();
env.add_function("calendarize", calendarize);
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>,
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!("aaaobject 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>,
pub url: Cow<'static, str>,
}
#[derive(Debug, Default, Hash, Copy, Clone, serde::Deserialize, serde::Serialize)]
pub enum Level {
Success,
#[default]
Info,
Warning,
Error,
}
#[derive(Debug, Hash, Clone, serde::Deserialize, serde::Serialize)]
pub struct Message {
pub message: Cow<'static, str>,
#[serde(default)]
pub level: Level,
}
impl Message {
const MESSAGE_KEY: &str = "session-message";
}
pub trait SessionMessages {
fn drain_messages(&mut self) -> Vec<Message>;
fn add_message(&mut self, _: Message) -> Result<(), ResponseError>;
}
impl SessionMessages for WritableSession {
fn drain_messages(&mut self) -> Vec<Message> {
let ret = self.get(Message::MESSAGE_KEY).unwrap_or_default();
self.remove(Message::MESSAGE_KEY);
ret
}
fn add_message(&mut self, message: Message) -> Result<(), ResponseError> {
let mut messages: Vec<Message> = self.get(Message::MESSAGE_KEY).unwrap_or_default();
messages.push(message);
self.insert(Message::MESSAGE_KEY, messages)?;
Ok(())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Hash)]
#[repr(transparent)]
pub struct IntPOST(pub i64);
impl serde::Serialize for IntPOST {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_i64(self.0)
}
}
impl<'de> serde::Deserialize<'de> for IntPOST {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct IntVisitor;
impl<'de> serde::de::Visitor<'de> for IntVisitor {
type Value = IntPOST;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("Int as a number or string")
}
fn visit_i64<E>(self, int: i64) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(IntPOST(int))
}
fn visit_str<E>(self, int: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
int.parse().map(IntPOST).map_err(serde::de::Error::custom)
}
}
deserializer.deserialize_any(IntVisitor)
}
}