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);