add: mail base

This commit is contained in:
trisua 2025-07-26 22:18:32 -04:00
parent a337e0c7c1
commit 29155ddb0c
11 changed files with 211 additions and 3 deletions

View file

@ -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\",

View file

@ -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(),

View file

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

View file

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

View file

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

View file

@ -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<Vec<Letter>> {
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<Vec<Letter>> {
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<Letter> {
// 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<usize>) -> "UPDATE letters SET read_by = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.letter:{}");
}

View file

@ -14,6 +14,7 @@ mod invite_codes;
mod ipbans;
mod ipblocks;
mod journals;
mod letters;
mod memberships;
mod message_reactions;
mod messages;

View file

@ -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<usize>,
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<usize>,
}
impl Letter {
/// Create a new [`Letter`].
pub fn new(owner: usize, receivers: Vec<usize>, subject: String, content: String) -> Self {
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
created: unix_epoch_timestamp(),
owner,
receivers,
subject,
content,
read_by: Vec::new(),
}
}
}

View file

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

View file

@ -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;
}

View file

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