feat: add user send mail and sent box (#1122)

* feat: add user send mail client

* fix: align user mail navigation

* fix: shorten address credential action

* test: cover user mail ownership boundaries

* fix: address user mail review feedback

* fix: disambiguate user mail e2e heading

* fix: minimize shared sent box changes

* refactor: isolate user send mail page

* refactor: reuse bound address lookup

* fix: clarify user sent box naming

* refactor: decouple user send API from roles

* fix: align user send mail behavior

* fix: align user send role and rate limits

* test: isolate user send rate limits

* test: initialize rate limit worker database

* refactor: simplify user send rate limit

* refactor: inline user send rate limit path

* refactor: simplify user send limiter key

* refactor: keep existing rate limit behavior

* style: simplify user send rate limit condition

* style: group user send rate limit condition

* fix: bind user role token to account

* fix: keep user sender selection available
This commit is contained in:
Dream Hunter
2026-08-25 14:14:42 +08:00
committed by GitHub
parent dccca92928
commit 5dbb6107dd
20 changed files with 1483 additions and 19 deletions
+18 -9
View File
@@ -6,6 +6,22 @@ import { unbindTelegramByAddress } from '../telegram_api/common';
import i18n from '../i18n';
import { updateAddressUpdatedAt, commonGetUserRole, handleListQuery, hideObjectFields } from '../common';
export const getBindedAddressById = async (
c: Context<HonoCustomType>,
user_id: number | string,
address_id: number | string
): Promise<string | null> => {
if (!user_id || !address_id) {
return null;
}
const address = await c.env.DB.prepare(
`SELECT a.name FROM users_address ua`
+ ` JOIN address a ON a.id = ua.address_id`
+ ` WHERE ua.user_id = ? AND ua.address_id = ?`
).bind(user_id, address_id).first<string>('name');
return address ?? null;
}
const UserBindAddressModule = {
bind: async (c: Context<HonoCustomType>) => {
const { user_id } = c.get("userPayload");
@@ -158,17 +174,10 @@ const UserBindAddressModule = {
if (!address_id || !user_id) {
return c.text(msgs.InvalidAddressOrUserTokenMsg, 400)
}
// check users_address if address binded
const db_user_id = await c.env.DB.prepare(
`SELECT user_id FROM users_address WHERE address_id = ? and user_id = ?`
).bind(address_id, user_id).first("user_id");
if (!db_user_id) {
const name = await getBindedAddressById(c, user_id, address_id);
if (!name) {
return c.text(msgs.AddressNotBindedMsg, 400)
}
// generate jwt
const name = await c.env.DB.prepare(
`SELECT name FROM address WHERE id = ? `
).bind(address_id).first("name");
const jwt = await Jwt.sign({
address: name,
address_id: address_id
+8
View File
@@ -6,6 +6,7 @@ import bind_address from './bind_address';
import passkey from './passkey';
import oauth2 from './oauth2';
import user_mail_api from './user_mail_api';
import user_send_mail_api from './user_send_mail_api';
export const api = new Hono<HonoCustomType>();
@@ -17,6 +18,13 @@ api.get('/user_api/settings', settings.settings);
api.get('/user_api/mails', user_mail_api.getMails);
api.delete('/user_api/mails/:id', user_mail_api.deleteMail);
// send mail api
api.get('/user_api/address/:address_id/settings', user_send_mail_api.settings);
api.post('/user_api/address/:address_id/request_send_mail_access', user_send_mail_api.requestAccess);
api.post('/user_api/address/:address_id/send_mail', user_send_mail_api.send);
api.get('/user_api/sendbox', user_send_mail_api.listUserSendbox);
api.delete('/user_api/sendbox/:mail_id', user_send_mail_api.removeUserSendboxMail);
// user api
api.post('/user_api/login', user.login);
api.post('/user_api/verify_code', user.verifyCode);
+117
View File
@@ -0,0 +1,117 @@
import { Context } from "hono";
import { handleListQuery } from "../common";
import i18n from "../i18n";
import { sendMail } from "../mails_api/send_mail_api";
import {
getSendBalanceState,
requestSendMailAccess,
} from "../mails_api/send_balance";
import { getBooleanValue } from "../utils";
import { getBindedAddressById } from "./bind_address";
const getAddressOrError = async (
c: Context<HonoCustomType>
): Promise<string | Response> => {
const addressId = Number(c.req.param("address_id"));
if (!Number.isInteger(addressId) || addressId <= 0) {
const msgs = i18n.getMessagesbyContext(c);
return c.text(msgs.AddressNotBindedMsg, 400);
}
const { user_id } = c.get("userPayload");
const address = await getBindedAddressById(c, user_id, addressId);
if (address) {
return address;
}
const msgs = i18n.getMessagesbyContext(c);
return c.text(msgs.AddressNotBindedMsg, 400);
}
const settings = async (c: Context<HonoCustomType>): Promise<Response> => {
const address = await getAddressOrError(c);
if (address instanceof Response) {
return address;
}
const { balance } = await getSendBalanceState(c, address);
return c.json({
address,
send_balance: balance || 0,
});
}
const requestAccess = async (c: Context<HonoCustomType>): Promise<Response> => {
const address = await getAddressOrError(c);
if (address instanceof Response) {
return address;
}
const msgs = i18n.getMessagesbyContext(c);
const result = await requestSendMailAccess(c, address);
if (result.status === "ok") {
return c.json({ status: "ok" });
}
if (result.status === "already_requested") {
return c.text(msgs.AlreadyRequestedMsg, 400);
}
return c.text(msgs.OperationFailedMsg, 500);
}
const send = async (c: Context<HonoCustomType>): Promise<Response> => {
const address = await getAddressOrError(c);
if (address instanceof Response) {
return address;
}
try {
const reqJson = await c.req.json();
await sendMail(c, address, reqJson);
} catch (error) {
console.error("Failed to send user mail", error);
return c.text(`Failed to send mail ${(error as Error).message}`, 400);
}
return c.json({ status: "ok" });
}
const listUserSendbox = async (c: Context<HonoCustomType>): Promise<Response> => {
const { user_id } = c.get("userPayload");
const { address, limit, offset } = c.req.query();
const filters = ["ua.user_id = ?"];
const params = [String(user_id)];
if (address) {
filters.push("sb.address = ?");
params.push(address);
}
const fromQuery = ` FROM users_address ua`
+ ` JOIN address a ON a.id = ua.address_id`
+ ` JOIN sendbox sb ON sb.address = a.name`
+ ` WHERE ${filters.join(" AND ")}`;
return await handleListQuery(c,
`SELECT sb.*${fromQuery}`,
`SELECT count(*) as count${fromQuery}`,
params, limit, offset, "sb.id desc"
);
}
const removeUserSendboxMail = async (c: Context<HonoCustomType>): Promise<Response> => {
const msgs = i18n.getMessagesbyContext(c);
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
return c.text(msgs.UserDeleteEmailDisabledMsg, 403);
}
const { user_id } = c.get("userPayload");
const { mail_id } = c.req.param();
const { success } = await c.env.DB.prepare(
`DELETE FROM sendbox WHERE id = ?`
+ ` AND EXISTS (`
+ `SELECT 1 FROM users_address ua`
+ ` JOIN address a ON a.id = ua.address_id`
+ ` WHERE ua.user_id = ? AND a.name = sendbox.address`
+ `)`
).bind(mail_id, user_id).run();
return c.json({ success });
}
export default {
settings,
requestAccess,
send,
listUserSendbox,
removeUserSendboxMail,
};
+10 -3
View File
@@ -65,6 +65,7 @@ app.use('/*', async (c, next) => {
c.req.path.startsWith("/api/new_address")
|| c.req.path.startsWith("/api/send_mail")
|| c.req.path.startsWith("/external/api/send_mail")
|| (c.req.path.startsWith("/user_api/address/") && c.req.path.endsWith("/send_mail"))
|| c.req.path.startsWith("/user_api/register")
|| c.req.path.startsWith("/user_api/verify_code")
) {
@@ -125,7 +126,8 @@ const checkUserPayload = async (
}
const checkoutUserRolePayload = async (
c: Context<HonoCustomType>
c: Context<HonoCustomType>,
userId?: number
): Promise<void> => {
try {
const token = c.req.raw.headers.get("x-user-access-token");
@@ -138,6 +140,7 @@ const checkoutUserRolePayload = async (
return;
}
if (typeof payload?.user_role !== "string") return;
if (userId !== undefined && payload.user_id !== userId) return;
c.set("userRolePayload", payload.user_role);
} catch (e) {
console.error(e);
@@ -202,8 +205,12 @@ app.use('/user_api/*', async (c, next) => {
console.error(e);
return c.text(msgs.UserTokenExpiredMsg, 401)
}
if (c.req.path.startsWith("/user_api/bind_address")) {
await checkoutUserRolePayload(c);
if (
c.req.path.startsWith("/user_api/bind_address")
|| c.req.path.startsWith("/user_api/address/")
) {
const { user_id } = c.get("userPayload");
await checkoutUserRolePayload(c, user_id);
}
if (c.req.path.startsWith('/user_api/bind_address')
&& c.req.method === 'POST'