Implement mail aliases

This commit is contained in:
traxys 2023-10-09 13:13:39 +02:00
parent f09444d832
commit 1c2b4c408e
3 changed files with 152 additions and 18 deletions

View file

@ -0,0 +1,7 @@
-- Add migration script here
CREATE TABLE alias (
alias TEXT NOT NULL PRIMARY KEY,
type mail_type NOT NULL CHECK (type = 'alias'),
dest TEXT NOT NULL REFERENCES emails(mail),
FOREIGN KEY (alias, type) REFERENCES emails (mail, type)
);

View file

@ -1,4 +1,5 @@
use std::{ use std::{
cmp::Ordering,
collections::{HashMap, VecDeque}, collections::{HashMap, VecDeque},
fmt::Display, fmt::Display,
net::SocketAddr, net::SocketAddr,
@ -520,11 +521,28 @@ struct ListRecipient {
} }
#[derive(Serialize)] #[derive(Serialize)]
struct Alias { struct List {
mail: String, mail: String,
recipients: Vec<String>, recipients: Vec<String>,
} }
#[derive(Serialize)]
struct AliasedMail {
primary: String,
aliases: Vec<String>,
}
fn compare_mails(a: &str, b: &str) -> Ordering {
let a = a.to_lowercase();
let b = b.to_lowercase();
match (a.split_once('@'), b.split_once('@')) {
(Some((user_a, domain_a)), Some((user_b, domain_b))) => {
domain_a.cmp(domain_b).then(user_a.cmp(user_b))
}
_ => a.cmp(&b),
}
}
async fn home( async fn home(
state: State<Arc<AppState>>, state: State<Arc<AppState>>,
User(user): User, User(user): User,
@ -543,13 +561,36 @@ async fn home(
.fetch_all(&state.db) .fetch_all(&state.db)
.await?; .await?;
let mut alias_stream = sqlx::query!(
r#"
SELECT alias,dest FROM alias WHERE dest IN (SELECT mail FROM emails WHERE id = $1)
ORDER BY lower(substring(alias from position('@' in alias)+1 )),lower(alias)
"#,
user
)
.fetch(&state.db);
let mut aliased_mails: HashMap<_, Vec<String>> =
mails.into_iter().map(|a| (a.mail, Vec::new())).collect();
while let Some(alias) = alias_stream.try_next().await? {
aliased_mails
.get_mut(&alias.dest)
.unwrap()
.push(alias.alias);
}
let aliased_mails: Vec<_> = aliased_mails
.into_iter()
.map(|(primary, aliases)| AliasedMail { primary, aliases })
.sorted_by(|a, b| compare_mails(&a.primary, &b.primary))
.collect();
let lists = sqlx::query_as!( let lists = sqlx::query_as!(
Mail, Mail,
r#" r#"
SELECT mail SELECT mail
FROM emails FROM emails
WHERE id = $1 AND type = 'list' WHERE id = $1 AND type = 'list'
ORDER BY lower(substring(mail from position('@' in mail)+1 )),lower(mail)
"#, "#,
user user
) )
@ -572,16 +613,16 @@ async fn home(
lists.get_mut(&list.list).unwrap().push(list.recipient); lists.get_mut(&list.list).unwrap().push(list.recipient);
} }
let aliases: Vec<_> = lists let lists: Vec<_> = lists
.into_iter() .into_iter()
.map(|(mail, recipients)| Alias { mail, recipients }) .map(|(mail, recipients)| List { mail, recipients })
.sorted_by(|a, b| a.mail.cmp(&b.mail)) .sorted_by(|a, b| compare_mails(&a.mail, &b.mail))
.collect(); .collect();
let mut context = tera::Context::new(); let mut context = tera::Context::new();
context.insert("mails", &mails); context.insert("mails", &aliased_mails);
context.insert("mail_domain", &state.mail_domain); context.insert("mail_domain", &state.mail_domain);
context.insert("lists", &aliases); context.insert("lists", &lists);
if let Some(err) = query.user_error { if let Some(err) = query.user_error {
tracing::info!("User error: {err:?}"); tracing::info!("User error: {err:?}");
context.insert("user_error", &err.to_string()); context.insert("user_error", &err.to_string());
@ -649,17 +690,17 @@ async fn delete_list(
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
enum UserError { enum UserError {
MailAlreadyExists, MailAlreadyExists,
NameAlreadyExists(String), UnauthorizedAliasDest(String),
} }
impl Display for UserError { impl Display for UserError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
UserError::MailAlreadyExists => { UserError::MailAlreadyExists => {
write!(f, "email address is already used by another user") write!(f, "email address is already in use")
} }
UserError::NameAlreadyExists(n) => { UserError::UnauthorizedAliasDest(dest) => {
write!(f, "account name '{n}' is already used by another user") write!(f, "not authorized to use '{dest}' as an alias destination")
} }
} }
} }
@ -809,6 +850,66 @@ async fn delete_recipient(
Ok(Redirect::to("/")) Ok(Redirect::to("/"))
} }
#[derive(Deserialize, Debug)]
struct NewAlias {
mail: String,
alias: String,
}
#[tracing::instrument(skip(state))]
async fn alias_add(
state: State<Arc<AppState>>,
User(user): User,
Form(alias): Form<NewAlias>,
) -> Result<Redirect, Error> {
let mut tx = state.db.begin().await?;
let can_use_mail = sqlx::query!(
"SELECT COUNT(*) FROM emails WHERE id = $1 AND mail = $2",
user,
alias.mail
)
.fetch_one(&mut *tx)
.await?
.count
.expect("count should not be null")
> 0;
if !can_use_mail {
return Ok(UserError::UnauthorizedAliasDest(alias.mail).into());
}
let has_mail = sqlx::query!("SELECT COUNT(*) FROM emails WHERE mail = $1", alias.alias)
.fetch_one(&mut *tx)
.await?
.count
.expect("count should not be null");
if has_mail != 0 {
return Ok(UserError::MailAlreadyExists.into());
}
sqlx::query!(
"INSERT INTO emails (id, mail, type) VALUES ($1, $2, 'alias') ON CONFLICT DO NOTHING",
user,
alias.alias
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"INSERT INTO alias (alias, type, dest) VALUES ($1, 'alias', $2) ON CONFLICT DO NOTHING",
alias.alias,
alias.mail
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Redirect::to("/"))
}
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
struct Password { struct Password {
password: SecretString, password: SecretString,
@ -875,6 +976,7 @@ async fn main() -> color_eyre::Result<()> {
.route("/list/recipient/delete", post(delete_recipient)) .route("/list/recipient/delete", post(delete_recipient))
.route("/list/delete", post(delete_list)) .route("/list/delete", post(delete_list))
.route("/password", post(set_password)) .route("/password", post(set_password))
.route("/alias/add", post(alias_add))
.fallback(page_not_found) .fallback(page_not_found)
.with_state(Arc::new(AppState { .with_state(Arc::new(AppState {
db, db,

View file

@ -116,12 +116,36 @@
<h2 class="title is-2 mt-2">Mails</h2> <h2 class="title is-2 mt-2">Mails</h2>
<ul class="list-group"> <ul class="list-group">
{% for mail in mails %} {% for mail in mails %}
<li class="list-group-item d-flex justify-content-between align-items-center"> <li class="list-group-item d-flex flex-column">
{{ mail.mail }} <div class="d-flex justify-content-between align-items-center">
{{ mail.primary }}
{{ self::delete_modal(modal_id="mailDelete" ~ loop.index, {{ self::delete_modal(modal_id="mailDelete" ~ loop.index,
confirm_text="Delete mail '" ~ mail.mail ~ "'", confirm_text="Delete mail '" ~ mail.primary ~ "'",
action="/mail/delete", action="/mail/delete",
payload=["mail", mail.mail]) payload=["mail", mail.primary])
}}
</div>
<h3>Aliases</h3>
<ul class="list-group mt-1">
{% set alias_idx = loop.index %}
{% for alias in mail.aliases %}
<li class="list-group-item d-flex justify-content-between align-items-center">
{{ alias }}
{{ self::delete_modal(modal_id="aliasDelete" ~ alias_idx ~ loop.index,
confirm_text="Delete alias '" ~ alias ~ "' (for '" ~ mail.primary ~ "')",
action="/alias/delete",
payload=["alias", alias])
}}
</li>
{% endfor %}
</ul>
{{ self::add_modal(modal_id="addAlias" ~ loop.index,
add_button="Add Alias",
button_classes="mt-2 w-25",
add_text="Add a alias",
action="/alias/add",
input_name="alias",
payload=["mail", mail.primary])
}} }}
</li> </li>
{% endfor %} {% endfor %}
@ -147,6 +171,7 @@
payload=["name", list.mail]) payload=["name", list.mail])
}} }}
</div> </div>
<h3>Recipients</h3>
<ul class="list-group mt-1"> <ul class="list-group mt-1">
{% set list_idx = loop.index %} {% set list_idx = loop.index %}
{% for recpt in list.recipients %} {% for recpt in list.recipients %}