feats: support muti email providers

This commit is contained in:
oiov
2025-10-16 15:48:04 +08:00
parent 9c3e9ddc0f
commit 940b02313c
41 changed files with 1567 additions and 232 deletions

View File

@@ -26,7 +26,9 @@ LinuxDo_CLIENT_SECRET=
# Email api (https://resend.com) for Auth login (NextAuth) and send email
# -----------------------------------------------------------------------------
RESEND_API_KEY=
RESEND_FROM_EMAIL="wrdo <support@wr.do>"
BREVO_API_KEY=
EMAIL_FROM=service@wr.do
EMAIL_FROM_NAME=WRDO
# Google Analytics
NEXT_PUBLIC_GOOGLE_ID=

View File

@@ -63,7 +63,7 @@ export default function AppConfigs({}: {}) {
if (configs?.enable_google_oauth) count++;
if (configs?.enable_github_oauth) count++;
if (configs?.enable_liunxdo_oauth) count++;
if (configs?.enable_resend_email_login) count++;
// if (configs?.enable_resend_email_login) count++;
if (configs?.enable_email_password_login) count++;
setLoginMethodCount(count);
}
@@ -133,62 +133,7 @@ export default function AppConfigs({}: {}) {
<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">
<div className="flex items-center justify-between gap-3 transition-all duration-100 hover:cursor-pointer hover:shadow-sm">
<p className="flex items-center gap-2 text-sm">
<Icons.pwdKey className="size-4" />
{t("Email Password")}
@@ -204,6 +149,61 @@ export default function AppConfigs({}: {}) {
}
/>
</div>
<div className="flex items-center justify-between gap-3 transition-all duration-100 hover:cursor-pointer hover:shadow-sm">
<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 transition-all duration-100 hover:cursor-pointer hover:shadow-sm">
<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 transition-all duration-100 hover:cursor-pointer hover:shadow-sm">
<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> */}
</>
)}
</CollapsibleContent>
@@ -499,7 +499,7 @@ export default function AppConfigs({}: {}) {
</p>
<p className="text-start text-xs text-muted-foreground">
{t(
"If enabled, forward all received emails to other platform email addresses (Send with Resend)",
"If enabled, forward all received emails to other platform email addresses (Send with Resend or Brevo)",
)}
</p>
</div>
@@ -840,6 +840,7 @@ export default function AppConfigs({}: {}) {
{t(
"Send email notifications for subdomain application status updates; Notifies administrators when users submit applications and notifies users of approval results; Only available when subdomain application mode is enabled",
)}
. (Brevo)
</p>
</div>
{configs && (

View File

@@ -288,9 +288,14 @@ export default function DomainList({ user, action }: DomainListProps) {
handleChangeStatus(value, "enable_email", domain)
}
/>
{domain.resend_api_key && (
<Icons.resend className="mx-0.5 size-4" />
)}
{domain.email_provider === "Resend" &&
domain.resend_api_key && (
<Icons.resend className="mx-0.5 size-4" />
)}
{domain.email_provider === "Brevo" &&
domain.brevo_api_key && (
<Icons.brevo className="mx-0.5 size-4" />
)}
</TableCell>
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
<Switch

View File

@@ -32,7 +32,9 @@ export async function POST(req: NextRequest) {
cf_email: target_domain.cf_email,
cf_record_types: target_domain.cf_record_types,
cf_api_key_encrypted: false,
email_provider: target_domain.email_provider,
resend_api_key: target_domain.resend_api_key,
brevo_api_key: target_domain.brevo_api_key,
max_short_links: target_domain.max_short_links,
max_email_forwards: target_domain.max_email_forwards,
max_dns_records: target_domain.max_dns_records,

View File

@@ -60,7 +60,9 @@ export async function POST(req: NextRequest) {
cf_email: data.cf_email,
cf_record_types: data.cf_record_types,
cf_api_key_encrypted: false,
email_provider: data.email_provider,
resend_api_key: data.resend_api_key,
brevo_api_key: data.brevo_api_key,
max_short_links: data.max_short_links,
max_email_forwards: data.max_email_forwards,
max_dns_records: data.max_dns_records,
@@ -95,7 +97,9 @@ export async function PUT(req: NextRequest) {
cf_api_key,
cf_email,
cf_record_types,
email_provider,
resend_api_key,
brevo_api_key,
min_url_length,
min_email_length,
min_record_length,
@@ -120,6 +124,8 @@ export async function PUT(req: NextRequest) {
cf_email,
cf_record_types,
cf_api_key_encrypted: false,
email_provider,
brevo_api_key,
resend_api_key,
min_url_length,
min_email_length,

View File

@@ -1,10 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { Resend } from "resend";
import { checkDomainIsConfiguratedResend } from "@/lib/dto/domains";
import { checkDomainIsConfiguratedEmailProvider } from "@/lib/dto/domains";
import { getUserSendEmailCount, saveUserSendEmail } from "@/lib/dto/email";
import { getPlanQuota } from "@/lib/dto/plan";
import { checkUserStatus } from "@/lib/dto/user";
import { brevoSendEmail } from "@/lib/email/brevo";
import { getCurrentUser } from "@/lib/session";
import { restrictByTimeRange } from "@/lib/team";
import { isValidEmail } from "@/lib/utils";
@@ -36,30 +37,26 @@ export async function POST(req: NextRequest) {
return NextResponse.json("Invalid email address", { status: 403 });
}
const resend_key = await checkDomainIsConfiguratedResend(
from.split("@")[1],
);
const { email_key, provider } =
await checkDomainIsConfiguratedEmailProvider(from.split("@")[1]);
if (!resend_key) {
if (!email_key) {
return NextResponse.json(
"This domain is not configured for sending emails",
{ status: 400 },
);
}
const resend = new Resend(resend_key);
const { error } = await resend.emails.send({
from,
to,
subject,
html,
});
if (error) {
console.log("Resend error:", error); // 如果删掉这句log下面一行读取error的message会返回undefined
return NextResponse.json(`${error.message}`, {
status: 400,
});
switch (provider) {
case "Resend":
const resend = new Resend(email_key);
await resend.emails.send({ from, to, subject, html });
break;
case "Brevo":
await brevoSendEmail({ from, to, subject, html });
break;
default:
break;
}
await saveUserSendEmail(user.id, from, to, subject, html);

View File

@@ -10,7 +10,9 @@ import { getDomainsByFeature } from "@/lib/dto/domains";
import { getPlanQuota } from "@/lib/dto/plan";
import { getMultipleConfigs } from "@/lib/dto/system-config";
import { checkUserStatus, getFirstAdminUser } from "@/lib/dto/user";
import { applyRecordEmailHtml, resend } from "@/lib/email";
import { brevoSendEmail } from "@/lib/email/brevo";
import { resend } from "@/lib/email/resend";
import { applyRecordEmailHtml } from "@/lib/email/templates";
import { reservedDomains } from "@/lib/enums";
import { getCurrentUser } from "@/lib/session";
import { generateSecret } from "@/lib/utils";
@@ -117,8 +119,10 @@ export async function POST(req: Request) {
}
const admin_user = await getFirstAdminUser();
if (configs.enable_subdomain_status_email_pusher && admin_user) {
await resend.emails.send({
from: env.RESEND_FROM_EMAIL,
await brevoSendEmail({
key: "", // env
from: env.EMAIL_FROM,
fromName: env.EMAIL_FROM_NAME,
to: admin_user.email || "",
subject: "New record pending approval",
html: applyRecordEmailHtml({
@@ -128,6 +132,7 @@ export async function POST(req: Request) {
type: record.type,
name: record.name,
content: record.content,
comment: record.comment,
}),
});
}

View File

@@ -5,7 +5,9 @@ import { updateUserRecordReview } from "@/lib/dto/cloudflare-dns-record";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { getMultipleConfigs } from "@/lib/dto/system-config";
import { checkUserStatus, getUserById } from "@/lib/dto/user";
import { applyRecordToUserEmailHtml, resend } from "@/lib/email";
import { brevoSendEmail } from "@/lib/email/brevo";
import { resend } from "@/lib/email/resend";
import { applyRecordToUserEmailHtml } from "@/lib/email/templates";
import { getCurrentUser } from "@/lib/session";
export async function POST(req: Request) {
@@ -72,8 +74,10 @@ export async function POST(req: Request) {
]);
const userInfo = await getUserById(userId);
if (configs.enable_subdomain_status_email_pusher && userInfo) {
await resend.emails.send({
from: env.RESEND_FROM_EMAIL,
await brevoSendEmail({
key: "",
from: env.EMAIL_FROM,
fromName: env.EMAIL_FROM_NAME,
to: userInfo.email || "",
subject: "Your subdomain has been applied",
html: applyRecordToUserEmailHtml({

View File

@@ -1,7 +1,8 @@
import { getConfiguredResendDomains } from "@/lib/dto/domains";
import { getConfiguredEmailDomains } from "@/lib/dto/domains";
import { OriginalEmail, saveForwardEmail } from "@/lib/dto/email";
import { getMultipleConfigs } from "@/lib/dto/system-config";
import { resend } from "@/lib/email";
import { brevoSendEmail } from "@/lib/email/brevo";
import { resend } from "@/lib/email/resend";
export async function POST(req: Request) {
try {
@@ -141,20 +142,19 @@ async function handleExternalForward(data: OriginalEmail, configs: any) {
throw new Error("No valid forward emails configured");
}
const sender = await getConfiguredResendDomains();
if (sender.length === 0) {
const senders = await getConfiguredEmailDomains();
if (senders.length === 0) {
throw new Error("No configured resend domains");
}
const { error } = await resend.emails.send({
from: `Forwarding@${sender[0].domain_name}`,
const options = {
from: `Forwarding@${senders[0].domain_name}`,
to: validEmails,
subject: data.subject ?? "No subject",
html: `${data.html ?? data.text} <br><hr><p style="font-size: '12px'; color: '#888'; font-family: 'monospace';text-align: 'center'">This email was forwarded from ${data.to}. Powered by <a href="https://wr.do">WR.DO</a>.</p>`,
});
if (error) {
console.log("[Resend Error]", error);
}
};
await brevoSendEmail(options);
}
async function handleNormalEmail(data: OriginalEmail) {

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.1.5",
"versionName": "1.1.6",
"versionCode": "1",
"start_url": "/",
"orientation": "portrait",

View File

@@ -2,13 +2,11 @@ import type { NextAuthConfig } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import Github from "next-auth/providers/github";
import Google from "next-auth/providers/google";
import Resend from "next-auth/providers/resend";
// import Resend from "next-auth/providers/resend";
import { env } from "@/env.mjs";
import { siteConfig } from "./config/site";
import { getVerificationEmailHtml, resend } from "./lib/email";
const linuxDoProvider: any = {
id: "linuxdo",
name: "Linux Do",
@@ -46,27 +44,27 @@ export default {
clientId: env.GITHUB_ID,
clientSecret: env.GITHUB_SECRET,
}),
Resend({
apiKey: env.RESEND_API_KEY,
from: env.RESEND_FROM_EMAIL || "wrdo <support@wr.do>",
async sendVerificationRequest({ identifier: email, url, provider }) {
try {
const { error } = await resend.emails.send({
from: provider.from || "no-reply@wr.do",
to: [email],
subject: "Verify your email address",
html: getVerificationEmailHtml({ url, appName: siteConfig.name }),
});
// Resend({
// apiKey: env.RESEND_API_KEY,
// from: env.EMAIL_FROM || "wrdo <support@wr.do>",
// async sendVerificationRequest({ identifier: email, url, provider }) {
// try {
// const { error } = await resend.emails.send({
// from: provider.from || "no-reply@wr.do",
// to: [email],
// subject: "Verify your email address",
// html: getVerificationEmailHtml({ url, appName: siteConfig.name }),
// });
if (error) {
throw new Error(`Resend error: ${JSON.stringify(error)}`);
}
} catch (error) {
console.error("Error sending verification email", error);
throw new Error("Error sending verification email");
}
},
}),
// if (error) {
// throw new Error(`Resend error: ${JSON.stringify(error)}`);
// }
// } catch (error) {
// console.error("Error sending verification email", error);
// throw new Error("Error sending verification email");
// }
// },
// }),
linuxDoProvider,
Credentials({
name: "Credentials",

View File

@@ -23,6 +23,13 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "../ui/collapsible";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { Switch } from "../ui/switch";
export type FormData = DomainFormData;
@@ -79,7 +86,9 @@ export function DomainForm({
cf_email: initData?.cf_email || "",
cf_record_types: initData?.cf_record_types || "CNAME,A,TXT",
cf_api_key_encrypted: initData?.cf_api_key_encrypted || false,
email_provider: initData?.email_provider || "",
resend_api_key: initData?.resend_api_key || "",
brevo_api_key: initData?.brevo_api_key || "",
min_url_length: initData?.min_url_length,
min_email_length: initData?.min_email_length,
min_record_length: initData?.min_record_length,
@@ -497,17 +506,16 @@ export function DomainForm({
<Collapsible className="relative mt-2 rounded-md bg-neutral-100 p-4 dark:bg-neutral-800">
<CollapsibleTrigger className="flex w-full items-center justify-between">
<h2 className="absolute left-2 top-5 flex gap-2 text-xs font-semibold text-neutral-400">
{t("Resend Configs")} ({t("Optional")})
<Icons.resend className="mx-0.5 size-4" />
<h2 className="absolute left-2 top-4 flex gap-2 text-xs font-semibold text-neutral-400">
{t("Email Service Configs")} ({t("Optional")})
</h2>
{ReadyBadge(
{/* {ReadyBadge(
currentEmailStatus,
isCheckedResendConfig,
isCheckingResend,
"resend",
)}
<Icons.chevronDown className="ml-2 size-4" />
)} */}
<Icons.chevronDown className="ml-auto size-4" />
</CollapsibleTrigger>
<CollapsibleContent>
{!currentEmailStatus && (
@@ -516,10 +524,45 @@ export function DomainForm({
{t("Associate with 'Email Service' status")}
</div>
)}
{/* 邮件服务商选择框 */}
<FormSectionColumns title="">
<div className="flex w-full items-start justify-between gap-2">
<Label className="mt-2.5 text-nowrap" htmlFor="zone_id">
{t("API Key")} ({t("send email service")}):
{t("Email Provider")}:
</Label>
<div className="w-full sm:w-3/5">
<Select
defaultValue={initData?.email_provider || "Resend"}
onValueChange={(value) => {
setValue("email_provider", value);
}}
>
<SelectTrigger className="flex-1 bg-neutral-50 shadow-inner">
<SelectValue placeholder="Select a email provider" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Resend">
<span className="flex items-center gap-1">
<Icons.resend className="size-4" />
<span>Resend</span>
</span>
</SelectItem>
<SelectItem value="Brevo">
<span className="flex items-center gap-1">
<Icons.brevo className="size-4" />
<span>Brevo</span>
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</FormSectionColumns>
<FormSectionColumns title="">
<div className="flex w-full items-start justify-between gap-2">
<Label className="mt-2.5 text-nowrap" htmlFor="zone_id">
{t("Resend API Key")}:
</Label>
<div className="w-full sm:w-3/5">
<Input
@@ -550,6 +593,40 @@ export function DomainForm({
</div>
</div>
</FormSectionColumns>
<FormSectionColumns title="">
<div className="flex w-full items-start justify-between gap-2">
<Label className="mt-2.5 text-nowrap" htmlFor="zone_id">
{t("Brevo API Key")}:
</Label>
<div className="w-full sm:w-3/5">
<Input
id="target"
className="flex-1 bg-neutral-50 shadow-inner"
size={32}
{...register("brevo_api_key")}
disabled={!currentEmailStatus}
/>
<div className="flex flex-col justify-between p-1">
{errors?.brevo_api_key ? (
<p className="pb-0.5 text-[13px] text-red-600">
{errors.brevo_api_key.message}
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
{t("Optional")}.{" "}
<Link
className="text-blue-500"
href="/docs/developer/email"
target="_blank"
>
{t("How to get brevo api key?")}
</Link>
</p>
)}
</div>
</div>
</div>
</FormSectionColumns>
</CollapsibleContent>
</Collapsible>

View File

@@ -217,7 +217,7 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
</Label>
<Input
id="email"
placeholder="name@example.com"
placeholder="email@example.com"
type="email"
autoCapitalize="none"
autoComplete="email"
@@ -262,9 +262,9 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
{t("Sign In / Sign Up")}
</Button>
<p className="rounded-md border border-dashed bg-muted px-3 py-2 text-xs text-muted-foreground">
{/* <p className="rounded-md border border-dashed bg-muted px-3 py-2 text-xs text-muted-foreground">
📢 {t("Unregistered users will automatically create an account")}.
</p>
</p> */}
</div>
</form>
);
@@ -360,7 +360,7 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
(loginMethod["resend"] || loginMethod["credentials"]) &&
rendeSeparator()}
{loginMethod["resend"] && loginMethod["credentials"] ? (
{/* {loginMethod["resend"] && loginMethod["credentials"] ? (
<Tabs defaultValue="resend">
<TabsList className="mb-2 w-full justify-center">
<TabsTrigger value="resend">{t("Email Code")}</TabsTrigger>
@@ -374,7 +374,9 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
{rendeResend()}
{rendeCredentials()}
</>
)}
)} */}
{loginMethod["credentials"] && <>{rendeCredentials()}</>}
</div>
);
}

View File

@@ -38,7 +38,7 @@ export function Notification() {
className="relative flex max-h-24 w-full items-center justify-center bg-muted text-sm text-primary"
>
<div
className="max-w-3xl flex-1 px-8 py-2.5 text-center"
className="flex-1 px-8 py-2.5 text-center"
dangerouslySetInnerHTML={{ __html: data.system_notification }}
/>

View File

@@ -620,6 +620,20 @@ export const Icons = {
</defs>
</svg>
),
brevo: ({ ...props }: LucideProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="128"
height="128"
viewBox="0 0 24 24"
{...props}
>
<path
fill="#059669"
d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12a12 12 0 0 0 12-12A12 12 0 0 0 12 0M7.2 4.8h5.747c2.34 0 3.895 1.406 3.895 3.516c0 1.022-.348 1.862-1.09 2.588C17.189 11.812 18 13.22 18 14.785c0 2.86-2.64 5.016-6.164 5.016H7.199v-15zm2.085 1.952v5.537h.07c.233-.432.858-.796 2.249-1.226c2.039-.659 3.037-1.52 3.037-2.655c0-.998-.766-1.656-1.924-1.656zm4.87 5.266c-.766.385-1.67.748-2.76 1.11c-1.229.387-2.11 1.386-2.11 2.407v2.315h2.365c2.387 0 4.149-1.34 4.149-3.155c0-1.067-.625-2.087-1.645-2.677z"
/>
</svg>
),
disabledLink: ({ ...props }: LucideProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -5,45 +5,63 @@ description: 如何配置项目中的邮件服务
<DocsLang en="/docs/developer/email" zh="/docs/developer/email-zh" />
在 WR.DO 项目中,有两个功能依赖于 Resend
- 邮箱验证登录(魔法链接)
- 邮件发送功能(如果你需要接收邮件功能,请参考 [cloudflare-email-worker](/docs/developer/cloudflare-email-worker))。
`.env` 文件中配置的 `RESEND_API_KEY` 和 `RESEND_FROM_EMAIL` 用于登录功能,
而邮件发送功能所需的 Resend 密钥需要你在登录后台管理面板(`/admin/domains`)后,在域名配置中自行添加。
<Callout type="note">
这两个功能可以使用同一个密钥,因为它们本质上都是通过 Resend 发送邮件。
<Callout type="warning" twClass="mt-4">
此模块配置在 v1.1.5 版本之后经历了较大改变,注意修改对应配置。
- 移除了 `RESEND_API_KEY`,替换为 `BREVO_API_KEY` (在较早版本仅默认使用 Resend 服务由于官方账号被Resend封禁不得不切换服务商所以做出此次变动)
- 默认使用 Resend 发送系统通知邮件替换为了 Brevo
- `RESEND_FROM_EMAIL` 替换为 `EMAIL_FROM`, 新增 `EMAIL_FROM_NAME` 环境变量
</Callout>
以下将演示如何配置登录所需的 Resend 密钥
本项目的邮件服务模块具备接收和发送邮件的能力,本篇将介绍如何配置项目中的邮件**发送**服务
## 步骤
> 如果你需要配置接收邮件功能,请参考文档 [cloudflare-email-worker](/docs/developer/cloudflare-email-worker)
<Callout type="note">
邮件部分配置类似于 [resend](https://resend.com/) 的文档。
如果你想查阅官方文档,可以参考
[这里](https://authjs.dev/getting-started/installation#setup-environment)。
</Callout>
目前支持的发件服务商:
<Steps>
- [Resend](https://resend.com) (免费额度:每天最多发送 100 封,支持绑定 1 个域名)
- [Brevo](https://www.brevo.com) (免费额度:每天最多发送 300 封,支持绑定多个域名)
### 创建账号
后续会根据需求接入其他发件方式。
如果你还没有 Resend 账号,请按照 [这里](https://resend.com/signup) 的注册流程操作。
## 环境变量
> Resend 免费账号提供每天发送 100 个邮件额度,绑定 1 个域名,足够一般用户使用。
```js
BREVO_API_KEY=your-brevo-api-key
EMAIL_FROM=support@your-domain.com
EMAIL_FROM_NAME=WR.DO
```
### 创建 API 密钥
注册并登录 Brevo 控制台 [app.brevo.com/settings/keys/api](https://app.brevo.com/settings/keys/api) 页面创建一个密钥,将其复制并粘贴到环境变量中。
注意,在此处配置的 `BREVO_API_KEY` 默认用于`子域名申请通知`功能不会用于发送邮件。也可将此key填入下方的域名配置中作为邮件服务模块的发件者。
## Brevo
在本项目中,有以下几处会使用到 Brevo 发送邮件:
- 邮件服务模块(`/email`
- 子域名申请通知(在系统设置中,默认关闭此功能)
- 邮箱验证功能(开发中 `/dashboard/settings`
同样在Brevo 控制台创建 API 密钥,然后回到系统的 localhost:3000/admin/system 页面, 在**域名管理**项中点击**添加域名**,并在子项**邮件服务商**中填写对应的 API 密钥保存即可:
![](/_static/docs/domain-form-email.png)
之后,你可以在 Brevo 控制台的 [Domain](https://app.brevo.com/senders/domain/list) 页面绑定域名,根据提示添加解析记录完成配置即可:
![](/_static/docs/brevo-domain.png)
最后在本系统依次添加域名,配置完成如下所示:
![](/_static/docs/domain-list.png)
## Resend
在本项目中,仅在邮件服务模块会使用 Resend 发送邮件。
#### 创建 API 密钥
登录 Resend 后,它会提示你创建第一个 API 密钥。
将其复制并粘贴到你的 `.env` 文件中:
将其复制并粘贴,后续步骤与 Brevo 类似,需要先绑定域名并配置解析记录。
```js
RESEND_API_KEY = re_your_resend_api_key;
RESEND_FROM_EMAIL="you <support@your-domain.com>"
````
</Steps>

View File

@@ -1,50 +1,68 @@
---
title: Email
description: How to manage emails in this project.
title: Email Configuration
description: How to configure email services in your project
---
<DocsLang en="/docs/developer/email" zh="/docs/developer/email-zh" />
In the WR.DO project, there are two features that rely on Resend,
one is email login (magic link), and the other is email sending feature (if you need to receive email feature,
please refer to /docs/developer/cloudflare-email-worker).
<Callout type="warning" twClass="mt-4">
The configuration of this module underwent significant changes after version v1.1.5. Please update your configuration accordingly:
The `RESEND_API_KEY` and `RESEND_SROM_SMAIL` configured in the `.env` file are used for login feature,
while the Resend key required for email sending feature needs to be added by
yourself in the domain configuration after logging into the admin panel (`/admin/domains`).
<Callout type="note">
Two features can use the same key, as both essentially use Resend to send emails.
- Removed `RESEND_API_KEY`, replaced with `BREVO_API_KEY` (earlier versions used Resend service by default. Due to the official account being banned by Resend, we had to switch service providers)
- Changed default email service for system notifications from Resend to Brevo
- `RESEND_FROM_EMAIL` replaced with `EMAIL_FROM`
- New environment variable added: `EMAIL_FROM_NAME`
</Callout>
The following will demonstrate how to configure the Resend key required for login.
The WR.DO project's email service module has the capability to receive and send emails. This guide will introduce how to configure the email **sending** service in your project.
## Steps
> If you need to configure email receiving functionality, please refer to the [cloudflare-email-worker](/docs/developer/cloudflare-email-worker) documentation.
<Callout type="note">
The email part is similar at the [resend](https://resend.com/) documentation.
You can find the official documentation
[here](https://authjs.dev/getting-started/installation#setup-environment) if
you want.
</Callout>
Currently supported email service providers:
<Steps>
- [Resend](https://resend.com) (Free tier: up to 100 emails per day, supports binding 1 domain)
- [Brevo](https://www.brevo.com) (Free tier: up to 300 emails per day, supports binding multiple domains)
### Create an account
Additional email service providers will be integrated based on requirements.
If don't have an account on Resend, just follow their steps after signup [here](https://resend.com/signup).
> Resend's free account offers a daily email limit of 100 emails, bound to 1 domain name, which is sufficient for ordinary users.
### Create an API key
After signin on Resend, he propurse you to create your first API key.
Copy/paste in your `.env` file.
## Environment Variables
```js
RESEND_API_KEY = re_your_resend_api_key;
RESEND_FROM_EMAIL="you <support@your-domain.com>"
BREVO_API_KEY=your-brevo-api-key
EMAIL_FROM=support@your-domain.com
EMAIL_FROM_NAME=WR.DO
```
</Steps>
Register and log in to the Brevo console at [app.brevo.com/settings/keys/api](https://app.brevo.com/settings/keys/api) to create an API key. Copy and paste it into your environment variables.
Note: The `BREVO_API_KEY` configured here is used by default for the `subdomain application notification` feature and is not used for sending emails directly. You can also fill this key in the domain configuration below as the sender for the email service module.
## Brevo
In this project, Brevo is used to send emails in the following scenarios:
- Email service module (`/email`)
- Subdomain application notification (in system settings, this feature is disabled by default)
- Email verification functionality (under development `/dashboard/settings`)
Similarly, create an API key in the Brevo console, then go to the localhost:3000/admin/system page in your system. In the **Domain Management** section, click **Add Domain**, and fill in the corresponding API key in the **Email Service Provider** field and save:
![](/_static/docs/domain-form-email.png)
After that, you can bind your domain on the [Domain](https://app.brevo.com/senders/domain/list) page in the Brevo console. Follow the prompts to add DNS records to complete the configuration:
![](/_static/docs/brevo-domain.png)
Finally, add the domain in this system sequentially. The completed configuration will look like this:
![](/_static/docs/domain-list.png)
## Resend
In this project, Resend is only used for sending emails through the email service module.
#### Creating an API Key
After logging in to Resend, it will prompt you to create your first API key.
Copy and paste it. The subsequent steps are similar to Brevo - you need to bind your domain first and configure DNS records.

View File

@@ -59,8 +59,9 @@ pnpm install
| GOOGLE\_CLIENT\_SECRET | `123465` | Google OAuth 客户端的密钥。 |
| GITHUB\_ID | `123465` | GitHub OAuth 客户端的 ID。 |
| GITHUB\_SECRET | `123465` | GitHub OAuth 客户端的密钥。 |
| RESEND\_API\_KEY | `123465` | Resend 的 API 密钥。 |
| RESEND\_FROM\_EMAIL | `"you <support@your-domain.com>"` | 用于发送邮件的邮箱地址。 |
| BREVO_API_KEY | `123465` | Brevo API key |
| EMAIL_FROM | `support@your-domain.com` | 发件人邮箱地址. |
| EMAIL_FROM_NAME | `WR.DO` | 发件人. |
| SCREENSHOTONE\_BASE\_URL | `https://api.example.com` | 待补充 |
| GITHUB\_TOKEN | `ghp_sscsfarwetqet` | [https://github.com/settings/tokens](https://github.com/settings/tokens) |
| NEXT_PUBLIC_GOOGLE_ID | `G-EWREW323` | Google Analytics ID |

View File

@@ -60,8 +60,9 @@ Copy/paste the `.env.example` in the `.env` file:
| GOOGLE_CLIENT_SECRET | `123465` | The secret of the Google OAuth client. |
| GITHUB_ID | `123465` | The ID of the GitHub OAuth client. |
| GITHUB_SECRET | `123465` | The secret of the GitHub OAuth client. |
| RESEND_API_KEY | `123465` | The API key for Resend. |
| RESEND_FROM_EMAIL | `"you <support@your-domain.com>"` | The email address to send emails from. |
| BREVO_API_KEY | `123465` | The API key for Brevo. |
| EMAIL_FROM | `"support@your-domain.com"` | The email address to send emails from. |
| EMAIL_FROM_NAME | `"WRDO"` | The name to send emails from. |
| SCREENSHOTONE_BASE_URL | `https://api.example.com` | The base URL of the screenshotone API. |
| GITHUB_TOKEN | `ghp_sscsfarwetqet` | https://github.com/settings/tokens |
| NEXT_PUBLIC_GOOGLE_ID | `G-EWREW323` | The ID of the Google Analytics. |

View File

@@ -82,7 +82,8 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000
* Google
* Github
* LinuxDo
* Resend 邮件验证
* Resend 邮件验证(1.1.5版本后已移除)
* 邮箱密码登陆
### Google 配置
@@ -121,6 +122,10 @@ LinuxDo_CLIENT_SECRET=
### Resend 邮件验证配置
<Callout type="warning" twClass="mt-4">
注意,此登陆方式已在 1.1.5 版本之后被移除,可忽略此配置,最新邮件相关配置请参考文档 [邮件配置](/docs/developer/email-zh)。
</Callout>
<Callout type="note">
邮件部分与 [resend](https://resend.com/) 的文档类似。
如果你想了解详细配置,可以查阅官方文档:

View File

@@ -77,7 +77,8 @@ We provide the following authentication services:
- Google
- Github
- LinuxDo
- Resend Email Verification
- Resend Email Verification (Removed after version 1.1.5)
- Email Password Login
### Google config
@@ -112,6 +113,11 @@ See config tutorial in [Connect LinuxDo](https://connect.linux.do).
### Resend Email Verification config
<Callout type="warning" twClass="mt-4">
Please note that this login method has been removed after version 1.1.5 and can be ignored.
For the latest email configuration, please refer to the document [Email Configuration](/docs/developer/email).
</Callout>
<Callout type="note">
The email part is similar at the [resend](https://resend.com/) documentation.
You can find the official documentation

View File

@@ -16,7 +16,9 @@ services:
LinuxDo_CLIENT_ID: ${LinuxDo_CLIENT_ID}
LinuxDo_CLIENT_SECRET: ${LinuxDo_CLIENT_SECRET}
RESEND_API_KEY: ${RESEND_API_KEY}
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL}
BREVO_API_KEY: ${BREVO_API_KEY}
EMAIL_FROM: ${EMAIL_FROM}
EMAIL_FROM_NAME: ${EMAIL_FROM_NAME}
NEXT_PUBLIC_EMAIL_R2_DOMAIN: ${NEXT_PUBLIC_EMAIL_R2_DOMAIN}
NEXT_PUBLIC_GOOGLE_ID: ${NEXT_PUBLIC_GOOGLE_ID}
SCREENSHOTONE_BASE_URL: ${SCREENSHOTONE_BASE_URL}

View File

@@ -17,7 +17,9 @@ services:
LinuxDo_CLIENT_ID: ${LinuxDo_CLIENT_ID}
LinuxDo_CLIENT_SECRET: ${LinuxDo_CLIENT_SECRET}
RESEND_API_KEY: ${RESEND_API_KEY}
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL}
BREVO_API_KEY: ${BREVO_API_KEY}
EMAIL_FROM: ${EMAIL_FROM}
EMAIL_FROM_NAME: ${EMAIL_FROM_NAME}
NEXT_PUBLIC_EMAIL_R2_DOMAIN: ${NEXT_PUBLIC_EMAIL_R2_DOMAIN}
NEXT_PUBLIC_GOOGLE_ID: ${NEXT_PUBLIC_GOOGLE_ID}
SCREENSHOTONE_BASE_URL: ${SCREENSHOTONE_BASE_URL}

View File

@@ -14,7 +14,9 @@ export const env = createEnv({
LinuxDo_CLIENT_SECRET: z.string().optional(),
DATABASE_URL: z.string().optional(),
RESEND_API_KEY: z.string().optional(),
RESEND_FROM_EMAIL: z.string().optional(),
BREVO_API_KEY: z.string().optional(),
EMAIL_FROM: z.string().optional(),
EMAIL_FROM_NAME: z.string().optional(),
SCREENSHOTONE_BASE_URL: z.string().optional(),
GITHUB_TOKEN: z.string().optional(),
},
@@ -34,7 +36,9 @@ export const env = createEnv({
GITHUB_SECRET: process.env.GITHUB_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
RESEND_API_KEY: process.env.RESEND_API_KEY,
RESEND_FROM_EMAIL: process.env.RESEND_FROM_EMAIL,
BREVO_API_KEY: process.env.BREVO_API_KEY,
EMAIL_FROM: process.env.EMAIL_FROM,
EMAIL_FROM_NAME: process.env.EMAIL_FROM_NAME,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_EMAIL_R2_DOMAIN: process.env.NEXT_PUBLIC_EMAIL_R2_DOMAIN,
NEXT_PUBLIC_SUPPORT_EMAIL: process.env.NEXT_PUBLIC_SUPPORT_EMAIL,

View File

@@ -16,7 +16,9 @@ export interface DomainConfig {
cf_email: string | null;
cf_record_types: string;
cf_api_key_encrypted: boolean;
email_provider: string;
resend_api_key: string | null;
brevo_api_key: string | null;
min_url_length: number;
min_email_length: number;
min_record_length: number;
@@ -118,26 +120,35 @@ export async function getDomainByName(domain_name: string) {
});
}
export async function checkDomainIsConfiguratedResend(domain_name: string) {
export async function checkDomainIsConfiguratedEmailProvider(
domain_name: string,
) {
try {
const domain = await prisma.domain.findUnique({
where: { domain_name },
select: {
email_provider: true,
resend_api_key: true,
brevo_api_key: true,
},
});
return domain?.resend_api_key;
if (domain?.email_provider === "Resend")
return { email_key: domain?.resend_api_key, provider: "Resend" };
if (domain?.email_provider === "Brevo")
return { email_key: domain?.brevo_api_key, provider: "Brevo" };
return { email_key: null, provider: null };
} catch (error) {
throw new Error(`Failed to fetch domain config: ${error.message}`);
}
}
export async function getConfiguredResendDomains() {
export async function getConfiguredEmailDomains() {
try {
const domains = await prisma.domain.findMany({
where: { resend_api_key: { not: null } },
where: { email_provider: "Brevo" },
select: {
domain_name: true,
brevo_api_key: true,
},
orderBy: {
updatedAt: "desc",

126
lib/email/brevo.ts Normal file
View File

@@ -0,0 +1,126 @@
import * as brevo from "@getbrevo/brevo";
export interface EmailRecipient {
email: string;
name?: string;
}
export interface EmailSender {
email: string;
name?: string;
}
export interface SendEmailOptions {
from?: string;
fromName?: string;
to: string | string[] | EmailRecipient | EmailRecipient[];
subject: string;
html: string;
sender?: EmailSender;
textContent?: string;
key?: string;
}
export interface EmailResponse {
success: boolean;
data?: any;
error?: any;
}
// 初始化 API 实例
let apiInstance: brevo.TransactionalEmailsApi | null = null;
function getApiInstance(key?: string): brevo.TransactionalEmailsApi {
if (!apiInstance) {
const apiKey = key || process.env.BREVO_API_KEY;
if (!apiKey) {
throw new Error("BREVO_API_KEY is not defined in environment variables");
}
apiInstance = new brevo.TransactionalEmailsApi();
apiInstance.setApiKey(brevo.TransactionalEmailsApiApiKeys.apiKey, apiKey);
}
return apiInstance;
}
// 处理收件人格式
function formatRecipients(
to: string | string[] | EmailRecipient | EmailRecipient[],
): EmailRecipient[] {
if (typeof to === "string") {
return [{ email: to, name: to.split("@")[0] }];
}
if (Array.isArray(to)) {
return to.map((item) => {
// 如果是字符串,转换为对象格式
if (typeof item === "string") {
return {
email: item,
name: item.includes("@") ? item.split("@")[0] : item,
};
}
// 如果已经是对象格式,直接返回
return item;
});
}
return [to];
}
/**
* 发送单封邮件
* @param options - 邮件配置选项
* @returns 返回发送结果的Promise
*/
export async function brevoSendEmail(
options: SendEmailOptions,
): Promise<EmailResponse> {
const {
to,
subject,
html,
from,
fromName,
sender = {
email: from || process.env.EMAIL_FROM || "service@wr.do",
name: fromName || process.env.EMAIL_FROM_NAME || "WRDO",
},
textContent,
key,
} = options;
try {
const api = getApiInstance(key);
const recipients = formatRecipients(to);
// 构建邮件对象
const sendSmtpEmail = new brevo.SendSmtpEmail();
sendSmtpEmail.to = recipients;
sendSmtpEmail.sender = sender;
sendSmtpEmail.subject = subject;
sendSmtpEmail.htmlContent = html;
if (textContent) {
sendSmtpEmail.textContent = textContent;
}
const data = await api.sendTransacEmail(sendSmtpEmail);
return { success: true, data };
} catch (error) {
console.error("Send email error:", error);
return { success: false, error };
}
}
/**
* 批量发送邮件
* @param emailList - 邮件列表
* @returns 返回所有邮件发送结果
*/
export async function sendBatchEmails(
emailList: SendEmailOptions[],
): Promise<PromiseSettledResult<EmailResponse>[]> {
const results = await Promise.allSettled(
emailList.map((emailOptions) => brevoSendEmail(emailOptions)),
);
return results;
}

5
lib/email/resend.ts Normal file
View File

@@ -0,0 +1,5 @@
import { Resend } from "resend";
import { env } from "@/env.mjs";
export const resend = new Resend(env.RESEND_API_KEY || "re_key");

View File

@@ -1,9 +1,3 @@
import { Resend } from "resend";
import { env } from "@/env.mjs";
export const resend = new Resend(env.RESEND_API_KEY || "re_key");
export function getVerificationEmailHtml({
url,
appName,
@@ -151,6 +145,7 @@ export function applyRecordEmailHtml({
type,
name,
content,
comment,
}: {
appUrl: string;
appName: string;
@@ -158,6 +153,7 @@ export function applyRecordEmailHtml({
type: string;
name: string;
content: string;
comment: string;
}) {
return `
<html>
@@ -233,6 +229,10 @@ export function applyRecordEmailHtml({
<th>Content</th>
<td>${content}</td>
</tr>
<tr>
<th>Comment</th>
<td>${comment}</td>
</tr>
</table>
<div class="button-container">

View File

@@ -11,7 +11,9 @@ export const createDomainSchema = z.object({
cf_email: z.string().optional(),
cf_record_types: z.string().optional(),
cf_api_key_encrypted: z.boolean().default(false),
email_provider: z.string().optional(),
resend_api_key: z.string().optional(),
brevo_api_key: z.string().optional(),
max_short_links: z.number().optional(),
max_email_forwards: z.number().optional(),
max_dns_records: z.number().optional(),

View File

@@ -138,6 +138,7 @@
"API Key": "API Key",
"send email service": "send email service",
"How to get resend api key?": "How to get resend api key?",
"How to get brevo api key?": "How to get brevo api key?",
"Analytics": "Analytics",
"Edit URL": "Edit URL",
"Plan Name": "Plan",
@@ -228,7 +229,11 @@
"Export": "Export",
"Export All": "Export All",
"Export Active": "Export Active",
"Export Basic Info": "Export Basic Info"
"Export Basic Info": "Export Basic Info",
"Email Service Configs": "Email Service Configs",
"Email Provider": "Email Provider",
"Resend API Key": "Resend API Key",
"Brevo API Key": "Brevo API Key"
},
"Components": {
"Dashboard": "Dashboard",
@@ -658,7 +663,7 @@
"Max File Count": "Max File Count",
"Password": "Password",
"Email Forwarding": "Email Forwarding",
"If enabled, forward all received emails to other platform email addresses (Send with Resend)": "If enabled, forward all received emails to other platform email addresses (Send with Resend)",
"If enabled, forward all received emails to other platform email addresses (Send with Resend or Brevo)": "If enabled, forward all received emails to other platform email addresses (Send with Resend or Brevo)",
"Forward Email Targets": "Forward Email Targets",
"Set forward email address targets, split by comma if more than one, such as: 1@a-com,2@b-com, Only works when email forwarding is enabled": "Set forward email address targets, split by comma if more than one, such as: 1@a.com,2@b.com, Only works when email forwarding is enabled",
"Email Forward White List": "Email Forward White List",

View File

@@ -138,6 +138,7 @@
"API Key": "API 密钥",
"send email service": "用于发送邮件服务",
"How to get resend api key?": "如何获取 Resend API 密钥?",
"How to get brevo api key?": "如何获取 Brevo API 密钥?",
"Analytics": "访客分析",
"Edit URL": "编辑短链",
"Plan Name": "计划名称",
@@ -228,7 +229,11 @@
"Export": "导出",
"Export All": "导出所有链接",
"Export Active": "导出有效链接",
"Export Basic Info": "导出基本信息"
"Export Basic Info": "导出基本信息",
"Email Service Configs": "邮件服务商",
"Email Provider": "邮件提供商",
"Resend API Key": "Resend 密钥",
"Brevo API Key": "Brevo 密钥"
},
"Components": {
"Dashboard": "用户面板",
@@ -658,7 +663,7 @@
"Save Modifications": "保存修改",
"Password": "账户密码",
"Email Forwarding": "邮件转发",
"If enabled, forward all received emails to other platform email addresses (Send with Resend)": "如果启用,则将所有收到的电子邮件转发到其他平台邮箱 (使用 Resend 发送)",
"If enabled, forward all received emails to other platform email addresses (Send with Resend or Brevo)": "如果启用,则将所有收到的电子邮件转发到其他平台邮箱 (使用 Resend 或 Brevo 发送)",
"Forward Email Targets": "目标收件箱",
"Set forward email address targets, split by comma if more than one, such as: 1@a-com,2@b-com, Only works when email forwarding is enabled": "设置转发目标邮箱多个邮件地址请用逗号分隔例如1@a.com,2@b.com仅在启用邮件转发时生效",
"Email Forward White List": "转发邮箱白名单",

View File

@@ -1,6 +1,6 @@
{
"name": "wr.do",
"version": "1.1.5",
"version": "1.1.6",
"author": {
"name": "oiov",
"url": "https://github.com/oiov"
@@ -27,6 +27,7 @@
"@auth/prisma-adapter": "^2.4.1",
"@aws-sdk/client-s3": "^3.840.0",
"@aws-sdk/s3-request-presigner": "^3.840.0",
"@getbrevo/brevo": "^3.0.1",
"@hookform/resolvers": "^3.9.0",
"@mantine/hooks": "^8.0.1",
"@prisma/client": "^5.17.0",
@@ -68,6 +69,7 @@
"@types/lodash-es": "^4.17.12",
"@types/ms": "^2.1.0",
"@types/next-pwa": "^5.6.9",
"@types/nodemailer": "^7.0.2",
"@types/three": "^0.176.0",
"@typescript-eslint/parser": "^7.16.1",
"@uiw/react-json-view": "2.0.0-alpha.26",
@@ -103,7 +105,6 @@
"next-pwa": "^5.6.0",
"next-themes": "^0.3.0",
"next-view-transitions": "^0.3.0",
"nodemailer": "^6.9.14",
"npm-run-all": "^4.1.5",
"peerjs": "^1.5.4",
"prop-types": "^15.8.1",

1018
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "domains" ADD COLUMN "brevo_api_key" TEXT;
ALTER TABLE "domains" ADD COLUMN "email_provider" TEXT NOT NULL DEFAULT 'Resend';

View File

@@ -265,7 +265,9 @@ model Domain {
cf_email String?
cf_record_types String @default("CNAME,A,TXT")
cf_api_key_encrypted Boolean
email_provider String @default("resend")
resend_api_key String?
brevo_api_key String?
max_short_links Int?
max_email_forwards Int?
max_dns_records Int?

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

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.1.5",
"versionName": "1.1.6",
"versionCode": "1",
"start_url": "/",
"orientation": "portrait",

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.1.5",
"versionName": "1.1.6",
"versionCode": "1",
"start_url": "/",
"orientation": "portrait",

File diff suppressed because one or more lines are too long