diff --git a/migrations/20231008140942_alias.sql b/migrations/20231008140942_alias.sql new file mode 100644 index 0000000..78b2be9 --- /dev/null +++ b/migrations/20231008140942_alias.sql @@ -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) +); diff --git a/src/main.rs b/src/main.rs index 39eaec1..54834b0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ use std::{ + cmp::Ordering, collections::{HashMap, VecDeque}, fmt::Display, net::SocketAddr, @@ -520,11 +521,28 @@ struct ListRecipient { } #[derive(Serialize)] -struct Alias { +struct List { mail: String, recipients: Vec, } +#[derive(Serialize)] +struct AliasedMail { + primary: String, + aliases: Vec, +} + +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( state: State>, User(user): User, @@ -543,13 +561,36 @@ async fn home( .fetch_all(&state.db) .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> = + 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!( Mail, r#" SELECT mail FROM emails WHERE id = $1 AND type = 'list' - ORDER BY lower(substring(mail from position('@' in mail)+1 )),lower(mail) "#, user ) @@ -572,16 +613,16 @@ async fn home( lists.get_mut(&list.list).unwrap().push(list.recipient); } - let aliases: Vec<_> = lists + let lists: Vec<_> = lists .into_iter() - .map(|(mail, recipients)| Alias { mail, recipients }) - .sorted_by(|a, b| a.mail.cmp(&b.mail)) + .map(|(mail, recipients)| List { mail, recipients }) + .sorted_by(|a, b| compare_mails(&a.mail, &b.mail)) .collect(); let mut context = tera::Context::new(); - context.insert("mails", &mails); + context.insert("mails", &aliased_mails); context.insert("mail_domain", &state.mail_domain); - context.insert("lists", &aliases); + context.insert("lists", &lists); if let Some(err) = query.user_error { tracing::info!("User error: {err:?}"); context.insert("user_error", &err.to_string()); @@ -649,17 +690,17 @@ async fn delete_list( #[derive(Serialize, Deserialize, Debug)] enum UserError { MailAlreadyExists, - NameAlreadyExists(String), + UnauthorizedAliasDest(String), } impl Display for UserError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { UserError::MailAlreadyExists => { - write!(f, "email address is already used by another user") + write!(f, "email address is already in use") } - UserError::NameAlreadyExists(n) => { - write!(f, "account name '{n}' is already used by another user") + UserError::UnauthorizedAliasDest(dest) => { + write!(f, "not authorized to use '{dest}' as an alias destination") } } } @@ -809,6 +850,66 @@ async fn delete_recipient( Ok(Redirect::to("/")) } +#[derive(Deserialize, Debug)] +struct NewAlias { + mail: String, + alias: String, +} + +#[tracing::instrument(skip(state))] +async fn alias_add( + state: State>, + User(user): User, + Form(alias): Form, +) -> Result { + 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)] struct Password { password: SecretString, @@ -875,6 +976,7 @@ async fn main() -> color_eyre::Result<()> { .route("/list/recipient/delete", post(delete_recipient)) .route("/list/delete", post(delete_list)) .route("/password", post(set_password)) + .route("/alias/add", post(alias_add)) .fallback(page_not_found) .with_state(Arc::new(AppState { db, diff --git a/templates/home.html b/templates/home.html index 3332c0d..516e6d9 100644 --- a/templates/home.html +++ b/templates/home.html @@ -116,13 +116,37 @@

Mails

    {% for mail in mails %} -
  • - {{ mail.mail }} - {{ self::delete_modal(modal_id="mailDelete" ~ loop.index, - confirm_text="Delete mail '" ~ mail.mail ~ "'", - action="/mail/delete", - payload=["mail", mail.mail]) - }} +
  • +
    + {{ mail.primary }} + {{ self::delete_modal(modal_id="mailDelete" ~ loop.index, + confirm_text="Delete mail '" ~ mail.primary ~ "'", + action="/mail/delete", + payload=["mail", mail.primary]) + }} +
    +

    Aliases

    +
      + {% set alias_idx = loop.index %} + {% for alias in mail.aliases %} +
    • + {{ 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]) + }} +
    • + {% endfor %} +
    + {{ 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]) + }}
  • {% endfor %}
@@ -147,6 +171,7 @@ payload=["name", list.mail]) }} +

Recipients

    {% set list_idx = loop.index %} {% for recpt in list.recipients %}