feats: support catch-all for email

This commit is contained in:
oiov
2025-06-20 16:49:37 +08:00
parent ae97fe895e
commit 367da79ed9
12 changed files with 433 additions and 180 deletions
+322 -167
View File
@@ -8,26 +8,28 @@ import useSWR from "swr";
import { fetcher } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card } from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Icons } from "@/components/shared/icons";
import { SkeletonSection } from "@/components/shared/section-skeleton";
export default function AppConfigs({}: {}) {
const [isPending, startTransition] = useTransition();
const [loginMethodCount, setLoginMethodCount] = useState(0);
const { data: configs, isLoading } = useSWR<Record<string, any>>(
"/api/admin/configs",
fetcher,
);
const {
data: configs,
isLoading,
mutate,
} = useSWR<Record<string, any>>("/api/admin/configs", fetcher);
const [notification, setNotification] = useState("");
const [catchAllEmails, setCatchAllEmails] = useState("");
const t = useTranslations("Setting");
@@ -35,6 +37,9 @@ export default function AppConfigs({}: {}) {
if (!isLoading && configs?.system_notification) {
setNotification(configs.system_notification);
}
if (!isLoading && configs?.catch_all_emails) {
setCatchAllEmails(configs.catch_all_emails);
}
// 计算登录方式数量
if (!isLoading) {
let count = 0;
@@ -55,6 +60,7 @@ export default function AppConfigs({}: {}) {
});
if (res.ok) {
toast.success("Updated!");
mutate();
} else {
toast.error("Failed!", {
description: await res.text(),
@@ -66,180 +72,329 @@ export default function AppConfigs({}: {}) {
if (isLoading) {
return (
<>
<SkeletonSection />
<SkeletonSection />
<Skeleton className="h-48 w-full rounded-lg" />
</>
);
}
return (
<Card className="bg-neutral-50 dark:bg-neutral-900">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-lg font-bold">{t("App Configs")}</CardTitle>
</CardHeader>
<CardContent>
<div className="mt-3 space-y-6 border-t pt-6">
<div className="flex items-center justify-between space-x-2">
<div className="space-y-1 leading-none">
<p className="font-medium">{t("User Registration")}</p>
<p className="text-xs text-muted-foreground">
{t("Allow users to sign up")}
</p>
</div>
{configs && (
<Switch
defaultChecked={configs.enable_user_registration}
onCheckedChange={(v) =>
handleChange(v, "enable_user_registration", "BOOLEAN")
}
/>
)}
</div>
<Collapsible>
<CollapsibleTrigger className="flex w-full items-center justify-between">
<div className="space-y-1 text-start leading-none">
<p className="font-medium">{t("Login Methods")}</p>
<Card>
<Collapsible className="group">
<CollapsibleTrigger className="flex w-full items-center justify-between bg-neutral-50 px-4 py-5 dark:bg-neutral-900">
<div className="text-lg font-bold">{t("App Configs")}</div>
<Icons.chevronDown className="ml-auto size-4" />
<Icons.settings className="ml-3 size-4 transition-all group-hover:scale-110" />
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 border-t bg-neutral-100 p-4 dark:bg-neutral-800">
<div className="space-y-6">
<div className="flex items-center justify-between space-x-2">
<div className="space-y-1 leading-none">
<p className="font-medium">{t("User Registration")}</p>
<p className="text-xs text-muted-foreground">
{t("Select the login methods that users can use to log in")}
{t("Allow users to sign up")}
</p>
</div>
<Icons.chevronDown className="ml-auto mr-2 size-4" />
<Badge>{loginMethodCount}</Badge>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 space-y-3 rounded-md bg-neutral-100 p-3">
{configs && (
<>
<div className="flex items-center justify-between gap-3">
<p className="flex items-center gap-2 text-sm">
<Icons.github className="size-4" /> GitHub OAuth
</p>
<Switch
defaultChecked={configs.enable_github_oauth}
onCheckedChange={(v) =>
handleChange(v, "enable_github_oauth", "BOOLEAN")
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<p className="flex items-center gap-2 text-sm">
<Icons.google className="size-4" />
Google OAuth
</p>
<Switch
defaultChecked={configs.enable_google_oauth}
onCheckedChange={(v) =>
handleChange(v, "enable_google_oauth", "BOOLEAN")
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<p className="flex items-center gap-2 text-sm">
<img
src="/_static/images/linuxdo.webp"
alt="linuxdo"
className="size-4"
/>
LinuxDo OAuth
</p>
<Switch
defaultChecked={configs.enable_liunxdo_oauth}
onCheckedChange={(v) =>
handleChange(v, "enable_liunxdo_oauth", "BOOLEAN")
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<p className="flex items-center gap-2 text-sm">
<Icons.resend className="size-4" />
{t("Resend Email")}
</p>
<Switch
defaultChecked={configs.enable_resend_email_login}
onCheckedChange={(v) =>
handleChange(v, "enable_resend_email_login", "BOOLEAN")
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<p className="flex items-center gap-2 text-sm">
<Icons.pwdKey className="size-4" />
{t("Email Password")}
</p>
<Switch
defaultChecked={configs.enable_email_password_login}
onCheckedChange={(v) =>
handleChange(
v,
"enable_email_password_login",
"BOOLEAN",
)
}
/>
</div>
</>
)}
</CollapsibleContent>
</Collapsible>
<div className="flex items-center justify-between space-x-2">
<div className="space-y-1 leading-none">
<p className="font-medium">{t("Subdomain Apply Mode")}</p>
<p className="text-xs text-muted-foreground">
{t(
"Enable subdomain apply mode, each submission requires administrator review",
)}
</p>
</div>
{configs && (
<Switch
defaultChecked={configs.enable_subdomain_apply}
onCheckedChange={(v) =>
handleChange(v, "enable_subdomain_apply", "BOOLEAN")
}
/>
)}
</div>
<div className="flex flex-col items-start justify-start gap-3">
<div className="space-y-1 leading-none">
<p className="font-medium">{t("Notification")}</p>
<p className="text-xs text-muted-foreground">
{t(
"Set system notification, this will be displayed in the header",
)}
</p>
</div>
{configs && (
<div className="flex w-full items-start gap-2">
<Textarea
className="h-16 max-h-32 min-h-9 resize-y"
placeholder="Support HTML format, such as <div>info</div>"
rows={5}
defaultValue={configs.system_notification}
value={notification}
onChange={(e) => setNotification(e.target.value)}
<Switch
defaultChecked={configs.enable_user_registration}
onCheckedChange={(v) =>
handleChange(v, "enable_user_registration", "BOOLEAN")
}
/>
<Button
className="h-9 text-nowrap"
disabled={
isPending || notification === configs.system_notification
}
onClick={() =>
handleChange(notification, "system_notification", "STRING")
}
>
{isPending && (
<Icons.spinner className="mr-1 size-4 animate-spin" />
)}
</div>
<Collapsible>
<CollapsibleTrigger className="flex w-full items-center justify-between">
<div className="space-y-1 text-start leading-none">
<p className="font-medium">{t("Login Methods")}</p>
<p className="text-xs text-muted-foreground">
{t("Select the login methods that users can use to log in")}
</p>
</div>
<Icons.chevronDown className="ml-auto mr-2 size-4" />
<Badge>{loginMethodCount}</Badge>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 space-y-3 rounded-md bg-neutral-100 p-3 dark:bg-neutral-800">
{configs && (
<>
<div className="flex items-center justify-between gap-3">
<p className="flex items-center gap-2 text-sm">
<Icons.github className="size-4" /> GitHub OAuth
</p>
<Switch
defaultChecked={configs.enable_github_oauth}
onCheckedChange={(v) =>
handleChange(v, "enable_github_oauth", "BOOLEAN")
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<p className="flex items-center gap-2 text-sm">
<Icons.google className="size-4" />
Google OAuth
</p>
<Switch
defaultChecked={configs.enable_google_oauth}
onCheckedChange={(v) =>
handleChange(v, "enable_google_oauth", "BOOLEAN")
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<p className="flex items-center gap-2 text-sm">
<img
src="/_static/images/linuxdo.webp"
alt="linuxdo"
className="size-4"
/>
LinuxDo OAuth
</p>
<Switch
defaultChecked={configs.enable_liunxdo_oauth}
onCheckedChange={(v) =>
handleChange(v, "enable_liunxdo_oauth", "BOOLEAN")
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<p className="flex items-center gap-2 text-sm">
<Icons.resend className="size-4" />
{t("Resend Email")}
</p>
<Switch
defaultChecked={configs.enable_resend_email_login}
onCheckedChange={(v) =>
handleChange(
v,
"enable_resend_email_login",
"BOOLEAN",
)
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<p className="flex items-center gap-2 text-sm">
<Icons.pwdKey className="size-4" />
{t("Email Password")}
</p>
<Switch
defaultChecked={configs.enable_email_password_login}
onCheckedChange={(v) =>
handleChange(
v,
"enable_email_password_login",
"BOOLEAN",
)
}
/>
</div>
</>
)}
</CollapsibleContent>
</Collapsible>
<div className="flex flex-col items-start justify-start gap-3">
<div className="space-y-1 leading-none">
<p className="font-medium">{t("Notification")}</p>
<p className="text-xs text-muted-foreground">
{t(
"Set system notification, this will be displayed in the header",
)}
{t("Save")}
</Button>
</p>
</div>
)}
{configs && (
<div className="flex w-full items-start gap-2">
<Textarea
className="h-16 max-h-32 min-h-9 resize-y bg-white"
placeholder="Support HTML format, such as <div>info</div>"
rows={5}
// defaultValue={configs.system_notification}
value={notification}
onChange={(e) => setNotification(e.target.value)}
/>
<Button
className="h-9 text-nowrap"
disabled={
isPending || notification === configs.system_notification
}
onClick={() =>
handleChange(
notification,
"system_notification",
"STRING",
)
}
>
{isPending && (
<Icons.spinner className="mr-1 size-4 animate-spin" />
)}
{t("Save")}
</Button>
</div>
)}
</div>
</div>
</div>
</CardContent>
</CollapsibleContent>
</Collapsible>
<Collapsible className="group border-y">
<CollapsibleTrigger className="flex w-full items-center justify-between bg-neutral-50 px-4 py-5 dark:bg-neutral-900">
<div className="text-lg font-bold">{t("Email Configs")}</div>
<Icons.chevronDown className="ml-auto size-4" />
<Icons.mail className="ml-3 size-4 transition-all group-hover:scale-110" />
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 border-t bg-neutral-100 p-4 dark:bg-neutral-800">
<div className="space-y-6">
{/* Catch-All */}
<div className="flex items-center justify-between space-x-2">
<div className="space-y-1 leading-none">
<p className="flex items-center gap-1 font-medium">
Catch-All <Badge>Beta</Badge>
</p>
<p className="text-xs text-muted-foreground">
{t(
"Enable email catch-all, all user's email address which created on this platform will be redirected to the catch-all email address",
)}
</p>
</div>
{configs && (
<Switch
defaultChecked={configs.enable_email_catch_all}
onCheckedChange={(v) =>
handleChange(v, "enable_email_catch_all", "BOOLEAN")
}
/>
)}
</div>
<div className="flex flex-col items-start justify-start gap-3">
<div className="space-y-1 leading-none">
<p className="font-medium">{t("Catch-All Email Address")}</p>
<p className="text-xs text-muted-foreground">
{t(
"Set catch-all email address, split by comma if more than one, such as: 1@a-com,2@b-com, Only works when email catch all is enabled",
)}
</p>
</div>
{configs && (
<div className="flex w-full items-start gap-2">
<Textarea
className="h-16 max-h-32 min-h-9 resize-y bg-white dark:bg-neutral-700"
placeholder="1@a.com,2@b.com"
rows={5}
// defaultValue={configs.catch_all_emails}
value={catchAllEmails}
disabled={!configs.enable_email_catch_all}
onChange={(e) => setCatchAllEmails(e.target.value)}
/>
<Button
className="h-9 text-nowrap"
disabled={
isPending || catchAllEmails === configs.catch_all_emails
}
onClick={() =>
handleChange(catchAllEmails, "catch_all_emails", "STRING")
}
>
{isPending && (
<Icons.spinner className="mr-1 size-4 animate-spin" />
)}
{t("Save")}
</Button>
</div>
)}
</div>
{/* Message Pusher */}
<div className="flex items-center justify-between space-x-2">
<div className="space-y-1 leading-none">
<p className="flex items-center gap-1 font-medium">
{t("Message Pusher")}
</p>
<p className="text-xs text-muted-foreground">
{t(
"Push message to third-party services, such as Telegram, 飞书 etc",
)}
.
</p>
</div>
{configs && (
<Switch
defaultChecked={false}
disabled
// onCheckedChange={(v) =>
// handleChange(v, "enable_email_catch_all", "BOOLEAN")
// }
/>
)}
</div>
{/* Webhook */}
<div className="flex flex-col items-start justify-start gap-3">
<div className="space-y-1 leading-none">
<p className="font-medium">Webhook</p>
<p className="text-xs text-muted-foreground"></p>
</div>
{configs && (
<div className="flex w-full items-start gap-2">
<Textarea
className="h-16 max-h-32 min-h-9 resize-y bg-white dark:bg-neutral-700"
placeholder=""
rows={5}
// defaultValue={configs.catch_all_emails}
// value={catchAllEmails}
disabled
// onChange={(e) => setCatchAllEmails(e.target.value)}
/>
<Button
className="h-9 text-nowrap"
disabled
onClick={() =>
handleChange(catchAllEmails, "catch_all_emails", "STRING")
}
>
{isPending && (
<Icons.spinner className="mr-1 size-4 animate-spin" />
)}
{t("Save")}
</Button>
</div>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
<Collapsible className="group">
<CollapsibleTrigger className="flex w-full items-center justify-between bg-neutral-50 px-4 py-5 dark:bg-neutral-900">
<div className="text-lg font-bold">{t("Subdomain Configs")}</div>
<Icons.chevronDown className="ml-auto size-4" />
<Icons.globeLock className="ml-3 size-4 transition-all group-hover:scale-110" />
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 border-t bg-neutral-100 p-4 dark:bg-neutral-800">
<div className="space-y-6">
<div className="flex items-center justify-between space-x-2">
<div className="space-y-1 leading-none">
<p className="font-medium">{t("Subdomain Apply Mode")}</p>
<p className="text-xs text-muted-foreground">
{t(
"Enable subdomain apply mode, each submission requires administrator review",
)}
</p>
</div>
{configs && (
<Switch
defaultChecked={configs.enable_subdomain_apply}
onCheckedChange={(v) =>
handleChange(v, "enable_subdomain_apply", "BOOLEAN")
}
/>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
</Card>
);
}
+2 -4
View File
@@ -1,13 +1,11 @@
import { Skeleton } from "@/components/ui/skeleton";
import { DashboardHeader } from "@/components/dashboard/header";
import { SkeletonSection } from "@/components/shared/section-skeleton";
export default function DashboardRecordsLoading() {
export default function SystemSettingsLoading() {
return (
<>
<DashboardHeader heading="System Settings" text="" />
<SkeletonSection />
<SkeletonSection />
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
+2
View File
@@ -24,6 +24,8 @@ export async function GET(req: NextRequest) {
"enable_liunxdo_oauth",
"enable_resend_email_login",
"enable_email_password_login",
"enable_email_catch_all",
"catch_all_emails",
]);
return Response.json(configs, { status: 200 });
+56 -1
View File
@@ -1,4 +1,5 @@
import { OriginalEmail, saveForwardEmail } from "@/lib/dto/email";
import { getMultipleConfigs } from "@/lib/dto/system-config";
export async function POST(req: Request) {
try {
@@ -7,7 +8,34 @@ export async function POST(req: Request) {
if (!data) {
return Response.json("No email data received", { status: 400 });
}
await saveForwardEmail(data);
const configs = await getMultipleConfigs([
"enable_email_catch_all",
"catch_all_emails",
]);
if (configs.enable_email_catch_all) {
const validEmails = parseAndValidateEmails(configs.catch_all_emails);
if (validEmails.length === 0) {
return Response.json(
{ error: "No valid catch-all emails configured" },
{ status: 400 },
);
}
// 方案1: 转发给所有配置的邮箱
const forwardPromises = validEmails.map((email) =>
saveForwardEmail({ ...data, to: email }),
);
await Promise.all(forwardPromises);
// 方案2: 只想转发给第一个邮箱
// await saveForwardEmail({ ...data, to: validEmails[0] });
} else {
await saveForwardEmail(data);
}
return Response.json({ status: 200 });
} catch (error) {
@@ -15,3 +43,30 @@ export async function POST(req: Request) {
return Response.json({ status: 500 });
}
}
function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email.trim());
}
function parseAndValidateEmails(emailsString: string): string[] {
if (!emailsString || typeof emailsString !== "string") {
return [];
}
const emails = emailsString
.split(",")
.map((email) => email.trim())
.filter((email) => email.length > 0);
const validEmails = emails.filter((email) => isValidEmail(email));
if (validEmails.length !== emails.length) {
console.warn(
"Some invalid email addresses found:",
emails.filter((email) => !isValidEmail(email)),
);
}
return validEmails;
}
+1 -1
View File
@@ -3,7 +3,7 @@
"short_name": "WR.DO",
"description": "Shorten links with analytics, manage emails and control subdomains—all on one platform.",
"appid": "com.wr.do",
"versionName": "1.0.4",
"versionName": "1.0.5",
"versionCode": "1",
"start_url": "/",
"orientation": "portrait",
+8 -1
View File
@@ -486,6 +486,13 @@
"Email Password": "Email Password",
"Your Password": "Your Password",
"Update your password": "Update your password",
"At least 6 characters, Max 32 characters": "At least 6 characters, Max 32 characters"
"At least 6 characters, Max 32 characters": "At least 6 characters, Max 32 characters",
"Subdomain Configs": "Subdomain Configs",
"Email Configs": "Email Configs",
"Enable email catch-all, all user's email address which created on this platform will be redirected to the catch-all email address": "Enable email catch all, user can use email address which created on this platform to register",
"Catch-All Email Address": "Catch-All Email Address",
"Set catch-all email address, split by comma if more than one, such as: 1@a-com,2@b-com, Only works when email catch all is enabled": "Set catch-all email address, split by comma if more than one, such as: 1@a.com,2@b.com, Only works when email catch all is enabled",
"Message Pusher": "Message Pusher",
"Push message to third-party services, such as Telegram, 飞书 etc.": "Push message to third-party services, such as Telegram, 飞书 etc"
}
}
+9 -2
View File
@@ -477,7 +477,7 @@
"User Registration": "用户注册",
"Allow users to sign up": "是否允许用户注册",
"Subdomain Apply Mode": "子域名申请模式",
"Enable subdomain apply mode, each submission requires administrator review": "启用子域名申请模式,每次提交需要管理员审核",
"Enable subdomain apply mode, each submission requires administrator review": "启用子域名申请模式,用户每次提交新子域名需要管理员审核(邮件通知管理员审核,审核通过后邮件通知用户)",
"Notification": "系统通知",
"Set system notification, this will be displayed in the header": "设置系统通知,将在网页顶部显示",
"Login Methods": "登录方式",
@@ -486,6 +486,13 @@
"Email Password": "账号密码登录",
"Your Password": "账号密码",
"Update your password": "更新您的密码",
"At least 6 characters, Max 32 characters": "密码长度至少6位,最多32位"
"At least 6 characters, Max 32 characters": "密码长度至少6位,最多32位",
"Subdomain Configs": "子域配置",
"Email Configs": "电子邮件配置",
"Enable email catch-all, all user's email address which created on this platform will be redirected to the catch-all email address": "启用 Catch-All,所有用户在此平台创建的邮箱所接收的邮件都会被转发到 Catch-All 设置的邮箱地址",
"Catch-All Email Address": "Catch-All 邮箱地址",
"Set catch-all email address, split by comma if more than one, such as: 1@a-com,2@b-com, Only works when email catch all is enabled": "设置 Catch-All 邮箱地址白名单 (仅支持在此平台创建的邮箱),多个邮箱地址请用逗号分隔,例如:1@a.com,2@b.com。仅在启用 Catch-All 时生效",
"Message Pusher": "消息推送",
"Push message to third-party services, such as Telegram, 飞书 etc": "推送消息到第三方服务,例如 Telegram,飞书等"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wr.do",
"version": "1.0.4",
"version": "1.0.5",
"author": {
"name": "oiov",
"url": "https://github.com/oiov"
@@ -0,0 +1,29 @@
INSERT INTO "system_configs"
(
"key",
"value",
"type",
"description"
)
VALUES
(
'enable_email_catch_all',
'false',
'BOOLEAN',
'是否启用 Email Catch-all 功能'
);
INSERT INTO "system_configs"
(
"key",
"value",
"type",
"description"
)
VALUES
(
'catch_all_emails',
'',
'STRING',
'Email Catchall 邮箱列表,逗号分隔'
);
+1 -1
View File
@@ -3,7 +3,7 @@
"short_name": "WR.DO",
"description": "Shorten links with analytics, manage emails and control subdomains—all on one platform.",
"appid": "com.wr.do",
"versionName": "1.0.4",
"versionName": "1.0.5",
"versionCode": "1",
"start_url": "/",
"orientation": "portrait",
+1 -1
View File
@@ -3,7 +3,7 @@
"short_name": "WR.DO",
"description": "Shorten links with analytics, manage emails and control subdomains—all on one platform.",
"appid": "com.wr.do",
"versionName": "1.0.4",
"versionName": "1.0.5",
"versionCode": "1",
"start_url": "/",
"orientation": "portrait",
+1 -1
View File
File diff suppressed because one or more lines are too long