add: mail ui

This commit is contained in:
trisua 2025-08-02 16:04:50 -04:00
parent 2e60cbc464
commit b2a73d286b
24 changed files with 993 additions and 259 deletions

426
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package]
name = "tetratto"
version = "12.0.0"
version = "13.0.0"
edition = "2024"
authors.workspace = true
repository.workspace = true

View file

@ -139,6 +139,11 @@ pub const LITTLEWEB_BROWSER: &str = include_str!("./public/html/littleweb/browse
pub const MARKETPLACE_SELLER: &str = include_str!("./public/html/marketplace/seller.lisp");
pub const MAIL_RECEIVED: &str = include_str!("./public/html/mail/received.lisp");
pub const MAIL_SENT: &str = include_str!("./public/html/mail/sent.lisp");
pub const MAIL_COMPOSE: &str = include_str!("./public/html/mail/compose.lisp");
pub const MAIL_LETTER: &str = include_str!("./public/html/mail/letter.lisp");
// langs
pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml");
@ -365,6 +370,11 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
write_template!(html_path->"marketplace/seller.html"(crate::assets::MARKETPLACE_SELLER) -d "marketplace" --config=config --lisp plugins);
write_template!(html_path->"mail/received.html"(crate::assets::MAIL_RECEIVED) -d "mail" --config=config --lisp plugins);
write_template!(html_path->"mail/sent.html"(crate::assets::MAIL_SENT) --config=config --lisp plugins);
write_template!(html_path->"mail/compose.html"(crate::assets::MAIL_COMPOSE) --config=config --lisp plugins);
write_template!(html_path->"mail/letter.html"(crate::assets::MAIL_LETTER) --config=config --lisp plugins);
html_path
}

View file

@ -19,6 +19,7 @@ version = "1.0.0"
"general:link.journals" = "Journals"
"general:link.achievements" = "Achievements"
"general:link.little_web" = "Little web"
"general:link.mail" = "Mail"
"general:action.save" = "Save"
"general:action.delete" = "Delete"
"general:action.purge" = "Purge"
@ -203,7 +204,6 @@ version = "1.0.0"
"mod_panel:label.invited_by" = "Invited by"
"mod_panel:label.send_debug_payload" = "Send debug payload"
"mod_panel:label.ban_reason" = "Ban reason"
"mod_panel:action.send" = "Send"
"requests:label.requests" = "Requests"
"requests:label.community_join_request" = "Community join request"
@ -308,3 +308,12 @@ version = "1.0.0"
"marketplace:action.get_started" = "Get started"
"marketplace:action.finsh_setting_up_account" = "Finish setting up my account"
"marketplace:action.open_seller_dashboard" = "Open seller dashboard"
"mail:label.received" = "Received"
"mail:label.sent" = "Sent"
"mail:label.compose" = "Compose"
"mail:label.receivers" = "Receivers"
"mail:label.subject" = "Subject"
"mail:label.content" = "Content"
"mail:action.send" = "Send"
"mail:action.send_mail" = "Send mail"

View file

@ -189,6 +189,23 @@ macro_rules! user_banned {
};
}
#[macro_export]
macro_rules! check_user_is_blocked {
($data:expr, $user:ident, $other_user:ident) => {
($data
.get_userblock_by_initiator_receiver($other_user.id, $user.id)
.await
.is_ok()
| $data
.get_user_stack_blocked_users($other_user.id)
.await
.contains(&$user.id))
&& !$user
.permissions
.check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS)
};
}
#[macro_export]
macro_rules! check_user_blocked_or_private {
($user:expr, $other_user:ident, $data:ident, $jar:ident) => {
@ -244,20 +261,7 @@ macro_rules! check_user_blocked_or_private {
// check if we're blocked
if let Some(ref ua) = $user {
if ($data
.0
.get_userblock_by_initiator_receiver($other_user.id, ua.id)
.await
.is_ok()
| $data
.0
.get_user_stack_blocked_users($other_user.id)
.await
.contains(&ua.id))
&& !ua
.permissions
.check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS)
{
if crate::check_user_is_blocked!($data.0, ua, $other_user) {
let lang = get_lang!($jar, $data.0);
let mut context = initial_context(&$data.0.0.0, lang, &$user).await;
@ -341,18 +345,7 @@ macro_rules! check_user_blocked_or_private {
($user:expr, $other_user:ident, $data:ident, @api) => {
// check if we're blocked
if let Some(ref ua) = $user {
if ($data
.get_userblock_by_initiator_receiver($other_user.id, ua.id)
.await
.is_ok()
| $data
.get_user_stack_blocked_users($other_user.id)
.await
.contains(&ua.id))
&& !ua
.permissions
.check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS)
{
if crate::check_user_is_blocked!($data, ua, $other_user) {
return Json(
tetratto_core::model::Error::MiscError("You're blocked".to_string()).into(),
);

View file

@ -293,6 +293,12 @@ button.small.square,
height: 32px;
}
button.tiny.square,
.button.tiny.square {
width: 24px;
height: 24px;
}
button.big_icon svg,
.button.big_icon svg {
height: 16px;

View file

@ -113,6 +113,12 @@
("style" "color: var(--color-primary)")
("class" "flex items-center")
(text "{{ icon \"badge-check\" }}"))
(text "{%- endif %} {% if user.permissions|has_supporter -%}")
(span
("title" "Supporter")
("style" "color: var(--color-primary);")
("class" "flex items-center")
(text "{{ icon \"star\" }}"))
(text "{%- endif %} {% if user.permissions|has_staff_badge -%}")
(span
("title" "Staff")
@ -456,21 +462,25 @@
(div
("class" "w-full card-nest")
(div
("class" "card small notif_title flex items-center")
(text "{% if not notification.read -%}")
(svg
("width" "24")
("height" "24")
("viewBox" "0 0 24 24")
("style" "fill: var(--color-link)")
(circle
("cx" "12")
("cy" "12")
("r" "6")))
(text "{%- endif %}")
(b
("class" "no_p_margin")
(text "{{ notification.title|markdown|safe }}")))
("class" "card small notif_title flex gap-2 justify-between items-center")
(div
("class" "flex items-center")
(text "{% if not notification.read -%}")
(svg
("width" "24")
("height" "24")
("viewBox" "0 0 24 24")
("style" "fill: var(--color-link)")
(circle
("cx" "12")
("cy" "12")
("r" "6")))
(text "{%- endif %}")
(b
("class" "no_p_margin")
(text "{{ notification.title|markdown|safe }}")))
(span ("class" "date") (text "{{ notification.created }}")))
(div
("class" "card notif_content flex flex-col gap-2")
(span
@ -2451,3 +2461,85 @@
(span
(str (text "dialog:action.continue"))))))
(text "{%- endif %} {%- endmacro %}")
(text "{% macro letter_listing(letter, owner) -%}")
(div
("class" "card lowered flex gap-2 flex-row")
(a
("href" "/@{{ owner.username }}")
(text "{{ self::avatar(username=owner.username, size=\"32px\") }}"))
(div
("class" "flex flex-col")
(text "{{ self::full_username(user=owner) }}")
(div
("class" "flex items-center gap-2")
; read status
(text "{% if user.id in letter.read_by -%}")
(div ("class" "flex items-center green") (icon (text "mail-check")))
(text "{% else %}")
(div ("class" "flex items-center") (icon (text "mail")))
(text "{%- endif %}")
; subject
(a ("class" "flush") ("href" "/mail/letter/{{ letter.id }}") (b (text "{{ letter.subject }}"))))))
(text "{%- endmacro %}")
(text "{% macro letter(letter, owner, show_subject=true) -%}")
(div
("class" "card-nest")
(text "{% if show_subject -%}")
(div
("class" "card flex gap-2 flex-row")
(a
("href" "/@{{ owner.username }}")
(text "{{ self::avatar(username=owner.username, size=\"32px\") }}"))
(div
("class" "flex flex-col")
(text "{{ self::full_username(user=owner) }}")
(span
(b (text "{{ letter.subject }}"))
(text "{% if letter.replying_to -%}")
(a
("href" "/mail/letter/{{ letter.replying_to }}")
(text " (up)"))
(text "{%- endif %}"))
(div
("class" "flex flex-wrap gap-2")
(text "{% for receiver in letter.receivers %}")
(a
("href" "/api/v1/auth/user/find/{{ receiver }}")
(text "{{ components::avatar(username=receiver, selector_type=\"id\", size=\"18px\") }}"))
(text "{%- endfor %}"))))
(text "{% else %}")
(div
("class" "card small flex gap-2 flex-row")
(a
("href" "/@{{ owner.username }}")
(text "{{ self::avatar(username=owner.username, size=\"24px\") }}"))
(text "{{ self::full_username(user=owner) }}"))
(text "{%- endif %}")
(div
("class" "card flex flex-col gap-2")
(text "{{ letter.content|markdown|safe }}")
(hr)
(div
("class" "flex gap-2 items-center")
(a
("class" "button small lowered")
("href" "/mail/compose?receivers={{ owner.username }}&subject=Re%3A%20{{ letter.subject }}&replying_to={{ letter.id }}")
("title" "Reply")
(icon (text "reply")))
(a
("class" "button small lowered")
("href" "/mail/compose?receivers={% for receiver in letter.receivers %},id%3A{{ receiver }}{% endfor %}&subject=Re%3A%20{{ letter.subject }}&replying_to={{ letter.id }}")
("title" "Reply all")
(icon (text "reply-all")))
(text "{% if user and letter.owner == user.id -%}")
(button
("class" "small lowered red")
("onclick" "delete_letter('{{ letter.id }}')")
("title" "Delete")
(icon (text "trash")))
(text "{%- endif %}"))))
(text "{%- endmacro %}")

View file

@ -76,6 +76,11 @@
("title" "Chats")
(icon (text "message-circle"))
(str (text "communities:label.chats")))
(a
("href" "/mail")
("title" "Mail")
(icon (text "mail"))
(str (text "general:link.mail")))
(a
("href" "/journals/0/0")
(icon (text "notebook"))

View file

@ -0,0 +1,139 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Compose letter - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
(div
("class" "card-nest")
(div
("class" "card small items-center gap-2 flex justify-between")
(div
("class" "flex gap-2 items-center")
(icon (text "mail-plus"))
(str (text "mail:label.compose")))
(button
("onclick" "window.history.back()")
("class" "lowered small")
(icon (text "arrow-left"))
(str (text "general:action.back"))))
(form
("class" "card flex flex-col gap-2")
("onsubmit" "create_letter_from_form(event)")
(div
("class" "flex flex-col gap-1")
(span
(b (str (text "mail:label.receivers"))))
(div
("class" "flex flex-wrap gap-2 small card lowered")
(div ("id" "receivers") ("class" "flex flex-wrap gap-2"))
(button
("class" "small tiny big_icon square raised")
("onclick" "add_receiver()")
("type" "button")
(icon (text "plus")))))
(div
("class" "flex flex-col gap-1")
(label
("for" "subject")
(b (str (text "mail:label.subject"))))
(input
("type" "text")
("placeholder" "subject")
("required" "")
("name" "subject")
("id" "subject")))
(div
("class" "flex flex-col gap-1")
(label
("for" "content")
(b (str (text "mail:label.content"))))
(textarea
("placeholder" "content")
("required" "")
("name" "content")
("id" "content")))
(button
(icon (text "send-horizontal"))
(str (text "mail:action.send"))))))
(script
(text "globalThis.RECEIVERS = [];
globalThis.SEARCH_PARAMS = new URLSearchParams(window.location.search);
globalThis.add_receiver = async () => {
const username = await trigger(\"atto::prompt\", [\"Username:\"]);
if (!username) {
return;
}
RECEIVERS.push(username);
render_receivers();
}
globalThis.remove_receiver = (username) => {
RECEIVERS.splice(RECEIVERS.indexOf(username), 1);
render_receivers();
}
globalThis.render_receivers = () => {
const element = document.getElementById(\"receivers\");
element.innerHTML = \"\";
for (let receiver of RECEIVERS) {
const is_id = receiver.startsWith(\"id:\");
receiver = receiver.replaceAll(\"<\", \"&lt;\").replaceAll(\">\", \"&gt;\").replace(\"id:\", \"\");
element.innerHTML += `<button class=\"small lowered\" onclick=\"remove_receiver('${receiver}')\" type=\"button\">
<img class=\"avatar\" style=\"--size: 18px\" src=\"/api/v1/auth/user/${receiver}/avatar?selector_type=${is_id ? \"id\" : \"username\"}\" />
<span>${is_id ? \"...\" : receiver}</span>
</button>`;
}
}
globalThis.create_letter_from_form = async (e) => {
e.preventDefault();
await trigger(\"atto::debounce\", [\"letters::create\"]);
fetch(\"/api/v1/letters\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
content: e.target.content.value.trim(),
subject: e.target.subject.value.trim(),
receivers: RECEIVERS,
replying_to: SEARCH_PARAMS.get(\"replying_to\") || \"0\",
}),
})
.then((res) => res.json())
.then((res) => {
if (!res.ok) {
trigger(\"atto::toast\", [\"error\", res.message]);
} else {
e.target.reset();
window.location.href = `/mail/letter/${res.payload}`;
}
});
};
if (SEARCH_PARAMS.get(\"receivers\")) {
let r = SEARCH_PARAMS.get(\"receivers\");
if (r.startsWith(\",\")) {
r = r.replace(\",\", \"\");
}
RECEIVERS = r.split(\",\");
render_receivers();
}
if (SEARCH_PARAMS.get(\"subject\")) {
document.getElementById(\"subject\").value = SEARCH_PARAMS.get(\"subject\");
}"))
(text "{% endblock %}")

View file

@ -0,0 +1,49 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Letter - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
(text "{{ components::letter(letter=letter, owner=owner) }}")
(text "{% for letter in replies %}")
(text "{{ components::letter(letter=letter[1], owner=letter[0], show_subject=false) }}")
(text "{%- endfor %}")
(text "{{ components::pagination(page=page, items=replies|length) }}"))
(script
(text "globalThis.delete_letter = async (id) => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(`/api/v1/letters/${id}`, {
method: \"DELETE\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
fetch(\"/api/v1/letters/{{ letter.id }}/read\", {
method: \"POST\",
})
.then((res) => res.json())
.then((res) => {
if (!res.ok) {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
}
});"))
(text "{% endblock %}")

View file

@ -0,0 +1,43 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Received mail - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
(div
("class" "pillmenu")
(a
("href" "/mail")
("class" "active")
(str (text "mail:label.received")))
(a
("href" "/mail/sent")
(str (text "mail:label.sent"))))
; letters
(div
("class" "card-nest")
(div
("class" "card small flex items-center justify-between gap-2")
(div
("class" "flex items-center gap-2")
(icon (text "mailbox"))
(str (text "mail:label.received")))
(a
("href" "/mail/compose")
("class" "button small lowered")
(icon (text "plus"))
(str (text "mail:label.compose"))))
(div
("class" "card flex flex-col gap-2")
(text "{% for letter in list %}")
(text "{{ components::letter_listing(letter=letter[1], owner=letter[0]) }}")
(text "{% endfor %}")
; pagination
(text "{% if list|length == 0 -%}")
(i ("class" "fade") (text "Nothing yet!"))
(text "{% else %}")
(text "{{ components::pagination(page=page, items=list|length) }}")
(text "{%- endif %}"))))
(text "{% endblock %}")

View file

@ -0,0 +1,43 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Sent mail - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
(div
("class" "pillmenu")
(a
("href" "/mail")
(str (text "mail:label.received")))
(a
("href" "/mail/sent")
("class" "active")
(str (text "mail:label.sent"))))
; letters
(div
("class" "card-nest")
(div
("class" "card small flex items-center justify-between gap-2")
(div
("class" "flex items-center gap-2")
(icon (text "mailbox"))
(str (text "mail:label.sent")))
(a
("href" "/mail/compose")
("class" "button small lowered")
(icon (text "plus"))
(str (text "mail:label.compose"))))
(div
("class" "card flex flex-col gap-2")
(text "{% for letter in list %}")
(text "{{ components::letter_listing(letter=letter[1], owner=letter[0]) }}")
(text "{% endfor %}")
; pagination
(text "{% if list|length == 0 -%}")
(i ("class" "fade") (text "Nothing yet!"))
(text "{% else %}")
(text "{{ components::pagination(page=page, items=list|length) }}")
(text "{%- endif %}"))))
(text "{% endblock %}")

View file

@ -7,7 +7,7 @@
(div
("class" "card-nest")
(div
("class" "card")
("class" "card small")
(b (text "Error 😦")))
(div

View file

@ -252,13 +252,20 @@
(text "{{ icon \"shield-off\" }}")
(span
(text "{{ text \"auth:action.unblock\" }}")))
(text "{%- endif %} {% if not user.settings.private_chats or is_following_you %}")
(text "{%- endif %} {% if not profile.settings.private_chats or is_following_you %}")
(button
("onclick" "create_group_chat()")
("class" "lowered")
(text "{{ icon \"message-circle\" }}")
(span
(text "{{ text \"auth:action.message\" }}")))
(text "{%- endif %} {% if not profile.settings.private_mails or is_following_you %}")
(a
("href" "/mail/compose?receivers={{ profile.username }}")
("class" "button lowered")
(icon (text "mail-plus"))
(span
(str (text "mail:action.send_mail"))))
(text "{%- endif %} {% if is_helper -%}")
(a
("href" "/mod_panel/profile/{{ profile.id }}")

View file

@ -1700,6 +1700,7 @@
[\"private_last_seen\", true],
[\"private_communities\", true],
[\"private_chats\", true],
[\"private_mails\", true],
[\"require_account\", true],
];
@ -1830,6 +1831,14 @@
\"{{ profile.settings.private_chats }}\",
\"checkbox\",
],
[
[
\"private_mails\",
\"Only allow users I'm following to add send me mail\",
],
\"{{ profile.settings.private_mails }}\",
\"checkbox\",
],
[
[
\"private_communities\",

View file

@ -1,16 +1,27 @@
use axum::{response::IntoResponse, Extension, Json, extract::Path};
use axum::{
extract::{Path, Query},
response::IntoResponse,
Extension, Json,
};
use tetratto_core::model::{auth::Notification, mail::Letter, oauth, ApiReturn, Error};
use crate::{get_user_from_token, State, cookie::CookieJar};
use crate::{cookie::CookieJar, get_user_from_token, routes::pages::PaginatedQuery, State};
use super::CreateLetter;
pub async fn list_received_request(jar: CookieJar, data: Extension<State>) -> impl IntoResponse {
pub async fn list_received_request(
jar: CookieJar,
data: Extension<State>,
Query(props): Query<PaginatedQuery>,
) -> 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 {
let letters = match data
.get_received_letters_by_user(user.id, 12, props.page)
.await
{
Ok(l) => l,
Err(e) => return Json(e.into()),
};
@ -22,14 +33,18 @@ pub async fn list_received_request(jar: CookieJar, data: Extension<State>) -> im
})
}
pub async fn list_sent_request(jar: CookieJar, data: Extension<State>) -> impl IntoResponse {
pub async fn list_sent_request(
jar: CookieJar,
data: Extension<State>,
Query(props): Query<PaginatedQuery>,
) -> 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 {
let letters = match data.get_letters_by_user(user.id, 12, props.page).await {
Ok(l) => l,
Err(e) => return Json(e.into()),
};
@ -92,7 +107,7 @@ pub async fn delete_request(
pub async fn create_request(
jar: CookieJar,
data: Extension<State>,
Json(props): Json<CreateLetter>,
Json(mut props): Json<CreateLetter>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateLetters) {
@ -100,10 +115,51 @@ pub async fn create_request(
None => return Json(Error::NotAllowed.into()),
};
// check receivers
props.receivers.dedup();
let mut receivers = Vec::new();
if props.receivers.len() < 1 {
return Json(Error::DataTooShort("receivers".to_string()).into());
} else if props.receivers.len() > 10 {
return Json(Error::DataTooLong("receivers".to_string()).into());
}
for receiver in &props.receivers {
let other_user = match if receiver.starts_with("id:") {
data.get_user_by_id(match receiver.replace("id:", "").parse() {
Ok(x) => x,
Err(_) => continue,
})
.await
} else {
data.get_user_by_username(receiver).await
} {
Ok(ua) => ua,
Err(e) => return Json(e.into()),
};
if crate::check_user_is_blocked!(data, user, other_user) {
continue;
}
if other_user.settings.private_mails
&& data
.get_userfollow_by_initiator_receiver(other_user.id, user.id)
.await
.is_err()
{
continue;
}
receivers.push(other_user.id);
}
// ...
match data
.create_letter(Letter::new(
user.id,
props.receivers,
receivers,
props.subject,
props.content,
match props.replying_to.parse() {
@ -120,7 +176,7 @@ pub async fn create_request(
.create_notification(Notification::new(
"You've got mail!".to_string(),
format!(
"[@{}](/api/v1/auth/user/find/{}) has sent you a [letter](/mail/{}).",
"[@{}](/api/v1/auth/user/find/{}) has sent you a [letter](/mail/letter/{}).",
user.username, user.id, l.id
),
*x,
@ -135,7 +191,7 @@ pub async fn create_request(
Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: Some(l),
payload: Some(l.id.to_string()),
})
}
Err(e) => return Json(e.into()),
@ -163,7 +219,11 @@ pub async fn add_read_request(
}
if letter.read_by.contains(&user.id) {
return Json(Error::MiscError("Already marked as read".to_string()).into());
return Json(ApiReturn {
ok: true,
message: "Already marked as read".to_string(),
payload: (),
});
}
letter.read_by.push(user.id);

View file

@ -1219,7 +1219,7 @@ pub struct QueryAppData {
#[derive(Deserialize)]
pub struct CreateLetter {
pub receivers: Vec<usize>,
pub receivers: Vec<String>,
pub subject: String,
pub content: String,
pub replying_to: String,

View file

@ -0,0 +1,156 @@
use axum::{
extract::{Query, Path},
response::{Html, IntoResponse},
Extension,
};
use crate::cookie::CookieJar;
use tetratto_core::model::Error;
use crate::{assets::initial_context, get_lang, get_user_from_token, State};
use super::{render_error, PaginatedQuery};
/// `/mail`
pub async fn received_request(
jar: CookieJar,
Extension(data): Extension<State>,
Query(props): Query<PaginatedQuery>,
) -> impl IntoResponse {
let data = data.read().await;
let user = match get_user_from_token!(jar, data.0) {
Some(ua) => ua,
None => {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &None).await,
));
}
};
let list = match data
.0
.get_received_letters_by_user(user.id, 12, props.page)
.await
{
Ok(x) => match data.0.fill_letters(x).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
},
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert("list", &list);
context.insert("page", &props.page);
// return
Ok(Html(data.1.render("mail/received.html", &context).unwrap()))
}
/// `/mail/sent`
pub async fn sent_request(
jar: CookieJar,
Extension(data): Extension<State>,
Query(props): Query<PaginatedQuery>,
) -> impl IntoResponse {
let data = data.read().await;
let user = match get_user_from_token!(jar, data.0) {
Some(ua) => ua,
None => {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &None).await,
));
}
};
let list = match data.0.get_letters_by_user(user.id, 12, props.page).await {
Ok(x) => match data.0.fill_letters(x).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
},
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert("list", &list);
context.insert("page", &props.page);
// return
Ok(Html(data.1.render("mail/sent.html", &context).unwrap()))
}
/// `/mail/compose`
pub async fn compose_request(
jar: CookieJar,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = data.read().await;
let user = match get_user_from_token!(jar, data.0) {
Some(ua) => ua,
None => {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &None).await,
));
}
};
let lang = get_lang!(jar, data.0);
let context = initial_context(&data.0.0.0, lang, &Some(user)).await;
// return
Ok(Html(data.1.render("mail/compose.html", &context).unwrap()))
}
/// `/mail/letter`
pub async fn letter_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Query(props): Query<PaginatedQuery>,
) -> impl IntoResponse {
let data = data.read().await;
let user = match get_user_from_token!(jar, data.0) {
Some(ua) => ua,
None => {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &None).await,
));
}
};
let letter = match data.0.get_letter_by_id(id).await {
Ok(l) => l,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
if !letter.can_read(&user) {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &None).await,
));
}
let owner = match data.0.get_user_by_id(letter.owner).await {
Ok(l) => l,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
let replies = match data.0.get_letters_by_replying_to(id, 12, props.page).await {
Ok(x) => match data.0.fill_letters(x).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
},
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert("letter", &letter);
context.insert("owner", &owner);
context.insert("replies", &replies);
context.insert("page", &props.page);
// return
Ok(Html(data.1.render("mail/letter.html", &context).unwrap()))
}

View file

@ -5,6 +5,7 @@ pub mod developer;
pub mod forge;
pub mod journals;
pub mod littleweb;
pub mod mail;
pub mod marketplace;
pub mod misc;
pub mod mod_panel;
@ -17,10 +18,7 @@ use axum::{
};
use crate::cookie::CookieJar;
use serde::Deserialize;
use tetratto_core::{
model::{Error, auth::User},
};
use tetratto_core::model::{Error, auth::User};
use crate::{assets::initial_context, get_lang, InnerState};
pub fn routes() -> Router {
@ -160,6 +158,11 @@ pub fn routes() -> Router {
"/settings/seller",
get(marketplace::seller_settings_request),
)
// mail
.route("/mail", get(mail::received_request))
.route("/mail/sent", get(mail::sent_request))
.route("/mail/compose", get(mail::compose_request))
.route("/mail/letter/{id}", get(mail::letter_request))
}
pub fn lw_routes() -> Router {

View file

@ -1,7 +1,7 @@
[package]
name = "tetratto-core"
description = "The core behind Tetratto"
version = "12.0.2"
version = "13.0.0"
edition = "2024"
authors.workspace = true
repository.workspace = true

View file

@ -388,6 +388,7 @@ fn default_banned_usernames() -> Vec<String> {
"app".to_string(),
"services".to_string(),
"domains".to_string(),
"mail".to_string(),
]
}

View file

@ -5,5 +5,6 @@ CREATE TABLE IF NOT EXISTS letters (
receivers TEXT NOT NULL,
subject TEXT NOT NULL,
content TEXT NOT NULL,
read_by TEXT NOT NULL
read_by TEXT NOT NULL,
replying_to BIGINT NOT NULL
)

View file

@ -1,3 +1,5 @@
use std::collections::HashMap;
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};
@ -13,7 +15,7 @@ impl DataManager {
subject: get!(x->4(String)),
content: get!(x->5(String)),
read_by: serde_json::from_str(&get!(x->6(String))).unwrap(),
replying_to: get!(x->7(i32)) as usize,
replying_to: get!(x->7(i64)) as usize,
}
}
@ -23,7 +25,14 @@ impl DataManager {
///
/// # Arguments
/// * `id` - the ID of the user to fetch letters for
pub async fn get_letters_by_user(&self, id: usize) -> Result<Vec<Letter>> {
/// * `batch` - the limit of items in each page
/// * `page` - the page number
pub async fn get_letters_by_user(
&self,
id: usize,
batch: usize,
page: usize,
) -> Result<Vec<Letter>> {
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
@ -31,8 +40,8 @@ impl DataManager {
let res = query_rows!(
&conn,
"SELECT * FROM letters WHERE owner = $1 ORDER BY created DESC",
&[&(id as i64)],
"SELECT * FROM letters WHERE owner = $1 ORDER BY created DESC LIMIT $2 OFFSET $3",
&[&(id as i64), &(batch as i64), &((page * batch) as i64)],
|x| { Self::get_letter_from_row(x) }
);
@ -47,7 +56,14 @@ impl DataManager {
///
/// # 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>> {
/// * `batch` - the limit of items in each page
/// * `page` - the page number
pub async fn get_received_letters_by_user(
&self,
id: usize,
batch: usize,
page: usize,
) -> Result<Vec<Letter>> {
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
@ -55,8 +71,12 @@ impl DataManager {
let res = query_rows!(
&conn,
"SELECT * FROM letters WHERE receivers LIKE $1 ORDER BY created DESC",
&[&format!("%\"{id}\"%")],
"SELECT * FROM letters WHERE receivers LIKE $1 ORDER BY created DESC LIMIT $2 OFFSET $3",
&[
&format!("%{id}%"),
&(batch as i64),
&((page * batch) as i64)
],
|x| { Self::get_letter_from_row(x) }
);
@ -71,7 +91,14 @@ impl DataManager {
///
/// # Arguments
/// * `id` - the ID of the letter to fetch letters for
pub async fn get_letters_by_replying_to(&self, id: usize) -> Result<Vec<Letter>> {
/// * `batch` - the limit of items in each page
/// * `page` - the page number
pub async fn get_letters_by_replying_to(
&self,
id: usize,
batch: usize,
page: usize,
) -> Result<Vec<Letter>> {
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
@ -79,8 +106,8 @@ impl DataManager {
let res = query_rows!(
&conn,
"SELECT * FROM letters WHERE replying_to = $1 ORDER BY created DESC",
&[&(id as i64)],
"SELECT * FROM letters WHERE replying_to = $1 ORDER BY created DESC LIMIT $2 OFFSET $3",
&[&(id as i64), &(batch as i64), &((page * batch) as i64)],
|x| { Self::get_letter_from_row(x) }
);
@ -91,6 +118,24 @@ impl DataManager {
Ok(res.unwrap())
}
/// Fill a list of letters with their owner.
pub async fn fill_letters(&self, letters: Vec<Letter>) -> Result<Vec<(User, Letter)>> {
let mut seen_users: HashMap<usize, User> = HashMap::new();
let mut out = Vec::new();
for letter in letters {
out.push(if let Some(ua) = seen_users.get(&letter.owner) {
(ua.to_owned(), letter)
} else {
let user = self.get_user_by_id(letter.owner).await?;
seen_users.insert(letter.owner, user.clone());
(user, letter)
})
}
Ok(out)
}
/// Create a new letter in the database.
///
/// # Arguments
@ -109,6 +154,12 @@ impl DataManager {
return Err(Error::DataTooLong("content".to_string()));
}
if data.receivers.len() < 1 {
return Err(Error::DataTooShort("receivers".to_string()));
} else if data.receivers.len() > 10 {
return Err(Error::DataTooLong("receivers".to_string()));
}
// ...
let conn = match self.0.connect().await {
Ok(c) => c,

View file

@ -273,6 +273,9 @@ pub struct UserSettings {
/// If other users that you aren't following can add you to chats.
#[serde(default)]
pub private_chats: bool,
/// If other users that you aren't following can send you letters.
#[serde(default)]
pub private_mails: bool,
/// The user's status. Shows over connection info.
#[serde(default)]
pub status: String,