add: letters api

This commit is contained in:
trisua 2025-08-02 00:44:23 -04:00
parent 46e38042ce
commit 2e60cbc464
9 changed files with 247 additions and 31 deletions

View file

@ -20,7 +20,7 @@ tower-http = { version = "0.6.6", features = [
"set-header",
] }
axum = { version = "0.8.4", features = ["macros", "ws"] }
tokio = { version = "1.46.1", features = ["macros", "rt-multi-thread"] }
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] }
axum-extra = { version = "0.10.1", features = ["cookie", "multipart"] }
ammonia = "4.1.1"
tetratto-shared = { path = "../shared" }
@ -29,7 +29,7 @@ tetratto-l10n = { path = "../l10n" }
image = "0.25.6"
reqwest = { version = "0.12.22", features = ["json", "stream"] }
regex = "1.11.1"
serde_json = "1.0.141"
serde_json = "1.0.142"
mime_guess = "2.0.5"
cf-turnstile = "0.2.0"
contrasted = "0.1.3"
@ -42,7 +42,7 @@ async-stripe = { version = "0.41.0", features = [
"runtime-tokio-hyper",
"connect",
] }
emojis = "0.7.0"
emojis = "0.7.1"
webp = "0.3.0"
nanoneo = "0.2.0"
cookie = "0.18.1"

View file

@ -0,0 +1,178 @@
use axum::{response::IntoResponse, Extension, Json, extract::Path};
use tetratto_core::model::{auth::Notification, mail::Letter, oauth, ApiReturn, Error};
use crate::{get_user_from_token, State, cookie::CookieJar};
use super::CreateLetter;
pub async fn list_received_request(jar: CookieJar, data: Extension<State>) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadLetters) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let letters = match data.get_received_letters_by_user(user.id).await {
Ok(l) => l,
Err(e) => return Json(e.into()),
};
Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: Some(letters),
})
}
pub async fn list_sent_request(jar: CookieJar, data: Extension<State>) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadLetters) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let letters = match data.get_letters_by_user(user.id).await {
Ok(l) => l,
Err(e) => return Json(e.into()),
};
Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: Some(letters),
})
}
pub async fn get_request(
jar: CookieJar,
data: Extension<State>,
Path(id): Path<usize>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadLetters) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let letter = match data.get_letter_by_id(id).await {
Ok(l) => l,
Err(e) => return Json(e.into()),
};
if !letter.can_read(&user) {
return Json(Error::NotAllowed.into());
}
Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: Some(letter),
})
}
pub async fn delete_request(
jar: CookieJar,
data: Extension<State>,
Path(id): Path<usize>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLetters) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.delete_letter(id, &user).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: (),
}),
Err(e) => return Json(e.into()),
}
}
pub async fn create_request(
jar: CookieJar,
data: Extension<State>,
Json(props): Json<CreateLetter>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateLetters) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data
.create_letter(Letter::new(
user.id,
props.receivers,
props.subject,
props.content,
match props.replying_to.parse() {
Ok(x) => x,
Err(_) => return Json(Error::Unknown.into()),
},
))
.await
{
Ok(l) => {
// send notifications
for x in &l.receivers {
if let Err(e) = data
.create_notification(Notification::new(
"You've got mail!".to_string(),
format!(
"[@{}](/api/v1/auth/user/find/{}) has sent you a [letter](/mail/{}).",
user.username, user.id, l.id
),
*x,
))
.await
{
return Json(e.into());
}
}
// ...
Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: Some(l),
})
}
Err(e) => return Json(e.into()),
}
}
pub async fn add_read_request(
jar: CookieJar,
data: Extension<State>,
Path(id): Path<usize>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadLetters) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let mut letter = match data.get_letter_by_id(id).await {
Ok(l) => l,
Err(e) => return Json(e.into()),
};
if !letter.can_read(&user) {
return Json(Error::NotAllowed.into());
}
if letter.read_by.contains(&user.id) {
return Json(Error::MiscError("Already marked as read".to_string()).into());
}
letter.read_by.push(user.id);
match data.update_letter_read_by(id, letter.read_by).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}

View file

@ -5,6 +5,7 @@ pub mod channels;
pub mod communities;
pub mod domains;
pub mod journals;
pub mod letters;
pub mod notes;
pub mod notifications;
pub mod products;
@ -706,6 +707,13 @@ pub fn routes() -> Router {
post(products::update_description_request),
)
.route("/products/{id}/price", post(products::update_price_request))
// letters
.route("/letters", post(letters::create_request))
.route("/letters/{id}", get(letters::get_request))
.route("/letters/{id}", delete(letters::delete_request))
.route("/letters/{id}/read", post(letters::add_read_request))
.route("/letters/sent", get(letters::list_sent_request))
.route("/letters/received", get(letters::list_received_request))
}
pub fn lw_routes() -> Router {
@ -1208,3 +1216,11 @@ pub struct QueryAppData {
pub query: AppDataSelectQuery,
pub mode: AppDataSelectMode,
}
#[derive(Deserialize)]
pub struct CreateLetter {
pub receivers: Vec<usize>,
pub subject: String,
pub content: String,
pub replying_to: String,
}

View file

@ -17,10 +17,10 @@ default = ["database", "types", "sdk"]
[dependencies]
pathbufd = "0.1.4"
serde = { version = "1.0.219", features = ["derive"] }
toml = "0.9.2"
toml = "0.9.4"
tetratto-shared = { version = "12.0.6", path = "../shared" }
tetratto-l10n = { version = "12.0.0", path = "../l10n" }
serde_json = "1.0.141"
serde_json = "1.0.142"
totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"], optional = true }
reqwest = { version = "0.12.22", features = ["json", "multipart"], optional = true }
bitflags = { version = "2.9.1", optional = true }
@ -28,11 +28,11 @@ async-recursion = { version = "1.1.1", optional = true }
md-5 = { version = "0.10.6", optional = true }
base16ct = { version = "0.2.0", features = ["alloc"], optional = true }
base64 = { version = "0.22.1", optional = true }
emojis = "0.7.0"
emojis = "0.7.1"
regex = "1.11.1"
oiseau = { version = "0.1.2", default-features = false, features = [
"postgres",
"redis",
], optional = true }
paste = { version = "1.0.15", optional = true }
tokio = { version = "1.46.1", features = ["macros", "rt-multi-thread"] }
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] }

View file

@ -1,5 +1,6 @@
use serde::{Serialize, Deserialize};
use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp};
use crate::model::auth::User;
/// A letter is the most basic structure of the mail system. Letters are sent
/// and received by users.
@ -41,4 +42,9 @@ impl Letter {
replying_to,
}
}
/// Check if the given user can read the letter.
pub fn can_read(&self, user: &User) -> bool {
(user.id == self.owner) | self.receivers.contains(&user.id)
}
}

View file

@ -76,6 +76,8 @@ pub enum AppScope {
UserReadServices,
/// Read the user's products.
UserReadProducts,
/// Read the user's letters.
UserReadLetters,
/// Create posts as the user.
UserCreatePosts,
/// Create messages as the user.
@ -102,6 +104,8 @@ pub enum AppScope {
UserCreateServices,
/// Create products on behalf of the user.
UserCreateProducts,
/// Create letters on behalf of the user.
UserCreateLetters,
/// Delete posts owned by the user.
UserDeletePosts,
/// Delete messages owned by the user.
@ -146,6 +150,8 @@ pub enum AppScope {
UserManageProducts,
/// Manage the user's channel mutes.
UserManageChannelMutes,
/// Manage the user's letters.
UserManageLetters,
/// Edit posts created by the user.
UserEditPosts,
/// Edit drafts created by the user.

View file

@ -11,4 +11,4 @@ homepage.workspace = true
[dependencies]
pathbufd = "0.1.4"
serde = { version = "1.0.219", features = ["derive"] }
toml = "0.9.2"
toml = "0.9.4"

View file

@ -12,7 +12,7 @@ ammonia = "4.1.1"
chrono = "0.4.41"
hex_fmt = "0.3.0"
pulldown-cmark = "0.13.0"
rand = "0.9.1"
rand = "0.9.2"
regex = "1.11.1"
serde = { version = "1.0.219", features = ["derive"] }
sha2 = "0.10.9"