From 29155ddb0cb47a52e233eaa41f8024c8ece3802e Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 26 Jul 2025 22:18:32 -0400 Subject: [PATCH] add: mail base --- crates/app/src/public/html/mod/profile.lisp | 1 + crates/core/src/database/auth.rs | 19 ++- crates/core/src/database/common.rs | 1 + crates/core/src/database/drivers/common.rs | 1 + .../database/drivers/sql/create_letters.sql | 9 ++ crates/core/src/database/letters.rs | 144 ++++++++++++++++++ crates/core/src/database/mod.rs | 1 + crates/core/src/model/mail.rs | 35 +++++ crates/core/src/model/mod.rs | 1 + crates/core/src/model/permissions.rs | 1 + crates/shared/src/markdown.rs | 1 - 11 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 crates/core/src/database/drivers/sql/create_letters.sql create mode 100644 crates/core/src/database/letters.rs create mode 100644 crates/core/src/model/mail.rs diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp index 2b68c90..5a84aac 100644 --- a/crates/app/src/public/html/mod/profile.lisp +++ b/crates/app/src/public/html/mod/profile.lisp @@ -406,6 +406,7 @@ MANAGE_SERVICES: 1 << 3, MANAGE_PRODUCTS: 1 << 4, DEVELOPER_PASS: 1 << 5, + MANAGE_LETTERS: 1 << 6, }, \"secondary_role\", \"add_permission_to_secondary_role\", diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 3bd8678..fcd34be 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -100,14 +100,29 @@ impl DataManager { tokens: serde_json::from_str(&get!(x->6(String)).to_string()).unwrap(), permissions: FinePermission::from_bits(get!(x->7(i32)) as u32).unwrap(), is_verified: get!(x->8(i32)) as i8 == 1, - notification_count: get!(x->9(i32)) as usize, + notification_count: { + let x = get!(x->9(i32)); + if x > (usize::MAX - 1000) as i32 { + // we're a little too close to the maximum count, clearly something's gone wrong + 0 + } else { + x as usize + } + }, follower_count: get!(x->10(i32)) as usize, following_count: get!(x->11(i32)) as usize, last_seen: get!(x->12(i64)) as usize, totp: get!(x->13(String)), recovery_codes: serde_json::from_str(&get!(x->14(String)).to_string()).unwrap(), post_count: get!(x->15(i32)) as usize, - request_count: get!(x->16(i32)) as usize, + request_count: { + let x = get!(x->16(i32)); + if x > (usize::MAX - 1000) as i32 { + 0 + } else { + x as usize + } + }, connections: serde_json::from_str(&get!(x->17(String)).to_string()).unwrap(), stripe_id: get!(x->18(String)), grants: serde_json::from_str(&get!(x->19(String)).to_string()).unwrap(), diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index d37c330..5e10783 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -44,6 +44,7 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_SERVICES).unwrap(); execute!(&conn, common::CREATE_TABLE_PRODUCTS).unwrap(); execute!(&conn, common::CREATE_TABLE_APP_DATA).unwrap(); + execute!(&conn, common::CREATE_TABLE_LETTERS).unwrap(); for x in common::VERSION_MIGRATIONS.split(";") { execute!(&conn, x).unwrap(); diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index d2239a6..bccbfb9 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -32,3 +32,4 @@ pub const CREATE_TABLE_DOMAINS: &str = include_str!("./sql/create_domains.sql"); pub const CREATE_TABLE_SERVICES: &str = include_str!("./sql/create_services.sql"); pub const CREATE_TABLE_PRODUCTS: &str = include_str!("./sql/create_products.sql"); pub const CREATE_TABLE_APP_DATA: &str = include_str!("./sql/create_app_data.sql"); +pub const CREATE_TABLE_LETTERS: &str = include_str!("./sql/create_letters.sql"); diff --git a/crates/core/src/database/drivers/sql/create_letters.sql b/crates/core/src/database/drivers/sql/create_letters.sql new file mode 100644 index 0000000..f3100eb --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_letters.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS letters ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + receivers TEXT NOT NULL, + subject TEXT NOT NULL, + content TEXT NOT NULL, + read_by TEXT NOT NULL +) diff --git a/crates/core/src/database/letters.rs b/crates/core/src/database/letters.rs new file mode 100644 index 0000000..32f8187 --- /dev/null +++ b/crates/core/src/database/letters.rs @@ -0,0 +1,144 @@ +use crate::model::{auth::User, mail::Letter, permissions::SecondaryPermission, Error, Result}; +use crate::{auto_method, DataManager}; +use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow}; + +impl DataManager { + /// Get a [`Letter`] from an SQL row. + pub(crate) fn get_letter_from_row(x: &PostgresRow) -> Letter { + Letter { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + owner: get!(x->2(i64)) as usize, + receivers: serde_json::from_str(&get!(x->3(String))).unwrap(), + subject: get!(x->4(String)), + content: get!(x->5(String)), + read_by: serde_json::from_str(&get!(x->6(String))).unwrap(), + } + } + + auto_method!(get_letter_by_id(usize as i64)@get_letter_from_row -> "SELECT * FROM letters WHERE id = $1" --name="letter" --returns=Letter --cache-key-tmpl="atto.letter:{}"); + + /// Get all letters by user. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch letters for + pub async fn get_letters_by_user(&self, id: usize) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM letters WHERE owner = $1 ORDER BY created DESC", + &[&(id as i64)], + |x| { Self::get_letter_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("letter".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get all letters by user (where user is a receiver). + /// + /// # Arguments + /// * `id` - the ID of the user to fetch letters for + pub async fn get_received_letters_by_user(&self, id: usize) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM letters WHERE receivers LIKE $1 ORDER BY created DESC", + &[&format!("%\"{id}\"%")], + |x| { Self::get_letter_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("letter".to_string())); + } + + Ok(res.unwrap()) + } + + /// Create a new letter in the database. + /// + /// # Arguments + /// * `data` - a mock [`Letter`] object to insert + pub async fn create_letter(&self, data: Letter) -> Result { + // check values + if data.subject.len() < 2 { + return Err(Error::DataTooShort("subject".to_string())); + } else if data.subject.len() > 256 { + return Err(Error::DataTooLong("subject".to_string())); + } + + if data.content.len() < 2 { + return Err(Error::DataTooShort("content".to_string())); + } else if data.content.len() > 16384 { + return Err(Error::DataTooLong("content".to_string())); + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "INSERT INTO letters VALUES ($1, $2, $3, $4, $5, $6, $7)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.owner as i64), + &serde_json::to_string(&data.receivers).unwrap(), + &data.subject, + &data.content, + &serde_json::to_string(&data.read_by).unwrap(), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn delete_letter(&self, id: usize, user: &User) -> Result<()> { + let letter = self.get_letter_by_id(id).await?; + + // check user permission + if user.id != letter.owner + && !user + .secondary_permissions + .check(SecondaryPermission::MANAGE_LETTERS) + { + return Err(Error::NotAllowed); + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!(&conn, "DELETE FROM letters WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... + self.0.1.remove(format!("atto.letter:{}", id)).await; + Ok(()) + } + + auto_method!(update_letter_read_by(Vec) -> "UPDATE letters SET read_by = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.letter:{}"); +} diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 80b77a1..218bcd6 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -14,6 +14,7 @@ mod invite_codes; mod ipbans; mod ipblocks; mod journals; +mod letters; mod memberships; mod message_reactions; mod messages; diff --git a/crates/core/src/model/mail.rs b/crates/core/src/model/mail.rs new file mode 100644 index 0000000..849aaae --- /dev/null +++ b/crates/core/src/model/mail.rs @@ -0,0 +1,35 @@ +use serde::{Serialize, Deserialize}; +use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; + +/// A letter is the most basic structure of the mail system. Letters are sent +/// and received by users. +#[derive(Serialize, Deserialize)] +pub struct Letter { + pub id: usize, + pub created: usize, + pub owner: usize, + pub receivers: Vec, + pub subject: String, + pub content: String, + /// The ID of every use who has read the letter. Can be checked in the UI + /// with `user.id in letter.read_by`. + /// + /// This field can be updated by anyone in the letter's `receivers` field. + /// Other fields in the letter can only be updated by the letter's `owner`. + pub read_by: Vec, +} + +impl Letter { + /// Create a new [`Letter`]. + pub fn new(owner: usize, receivers: Vec, subject: String, content: String) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp(), + owner, + receivers, + subject, + content, + read_by: Vec::new(), + } + } +} diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index 7d7f19e..06c4149 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -7,6 +7,7 @@ pub mod communities; pub mod communities_permissions; pub mod journals; pub mod littleweb; +pub mod mail; pub mod moderation; pub mod oauth; pub mod permissions; diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs index 796b9f1..61ebb61 100644 --- a/crates/core/src/model/permissions.rs +++ b/crates/core/src/model/permissions.rs @@ -178,6 +178,7 @@ bitflags! { const MANAGE_SERVICES = 1 << 3; const MANAGE_PRODUCTS = 1 << 4; const DEVELOPER_PASS = 1 << 5; + const MANAGE_LETTERS = 1 << 6; const _ = !0; } diff --git a/crates/shared/src/markdown.rs b/crates/shared/src/markdown.rs index bacdd51..022e23d 100644 --- a/crates/shared/src/markdown.rs +++ b/crates/shared/src/markdown.rs @@ -12,7 +12,6 @@ pub fn render_markdown_dirty(input: &str) -> String { options.insert(Options::ENABLE_TABLES); options.insert(Options::ENABLE_HEADING_ATTRIBUTES); options.insert(Options::ENABLE_SUBSCRIPT); - options.insert(Options::ENABLE_SUPERSCRIPT); let parser = Parser::new_ext(input, options);