feat: add resend notification functionality

- Introduced a new notification type "resend" to the system.
- Added database schema for resend notifications including fields for apiKey, fromAddress, and toAddress.
- Implemented functions to create, update, and send resend notifications.
- Updated notification router to handle resend notifications with appropriate API endpoints.
- Enhanced existing notification services to support sending notifications via the Resend service.
- Modified various notification utilities to accommodate the new resend functionality.
This commit is contained in:
mhbdev
2026-01-24 21:29:19 +03:30
parent bc6647071f
commit 57eee45dbb
21 changed files with 7677 additions and 54 deletions

View File

@@ -15,6 +15,7 @@ import {
GotifyIcon,
LarkIcon,
NtfyIcon,
ResendIcon,
SlackIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
@@ -96,6 +97,23 @@ export const notificationSchema = z.discriminatedUnion("type", [
.min(1, { message: "At least one email is required" }),
})
.merge(notificationBaseSchema),
z
.object({
type: z.literal("resend"),
apiKey: z.string().min(1, { message: "API Key is required" }),
fromAddress: z
.string()
.min(1, { message: "From Address is required" })
.email({ message: "Email is invalid" }),
toAddresses: z
.array(
z.string().min(1, { message: "Email is required" }).email({
message: "Email is invalid",
}),
)
.min(1, { message: "At least one email is required" }),
})
.merge(notificationBaseSchema),
z
.object({
type: z.literal("gotify"),
@@ -158,6 +176,10 @@ export const notificationsMap = {
icon: <Mail size={29} className="text-muted-foreground" />,
label: "Email",
},
resend: {
icon: <ResendIcon className="text-muted-foreground" />,
label: "Resend",
},
gotify: {
icon: <GotifyIcon />,
label: "Gotify",
@@ -199,6 +221,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
api.notification.testDiscordConnection.useMutation();
const { mutateAsync: testEmailConnection, isLoading: isLoadingEmail } =
api.notification.testEmailConnection.useMutation();
const { mutateAsync: testResendConnection, isLoading: isLoadingResend } =
api.notification.testResendConnection.useMutation();
const { mutateAsync: testGotifyConnection, isLoading: isLoadingGotify } =
api.notification.testGotifyConnection.useMutation();
const { mutateAsync: testNtfyConnection, isLoading: isLoadingNtfy } =
@@ -224,6 +248,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
const emailMutation = notificationId
? api.notification.updateEmail.useMutation()
: api.notification.createEmail.useMutation();
const resendMutation = notificationId
? api.notification.updateResend.useMutation()
: api.notification.createResend.useMutation();
const gotifyMutation = notificationId
? api.notification.updateGotify.useMutation()
: api.notification.createGotify.useMutation();
@@ -260,7 +287,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
});
useEffect(() => {
if (type === "email" && fields.length === 0) {
if ((type === "email" || type === "resend") && fields.length === 0) {
append("");
}
}, [type, append, fields.length]);
@@ -328,6 +355,21 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "resend") {
form.reset({
appBuildError: notification.appBuildError,
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
apiKey: notification.resend?.apiKey,
toAddresses: notification.resend?.toAddresses,
fromAddress: notification.resend?.fromAddress,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "gotify") {
form.reset({
appBuildError: notification.appBuildError,
@@ -404,6 +446,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
telegram: telegramMutation,
discord: discordMutation,
email: emailMutation,
resend: resendMutation,
gotify: gotifyMutation,
ntfy: ntfyMutation,
lark: larkMutation,
@@ -486,6 +529,22 @@ export const HandleNotifications = ({ notificationId }: Props) => {
emailId: notification?.emailId || "",
serverThreshold: serverThreshold,
});
} else if (data.type === "resend") {
promise = resendMutation.mutateAsync({
appBuildError: appBuildError,
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
apiKey: data.apiKey,
fromAddress: data.fromAddress,
toAddresses: data.toAddresses,
name: data.name,
dockerCleanup: dockerCleanup,
notificationId: notificationId || "",
resendId: notification?.resendId || "",
serverThreshold: serverThreshold,
});
} else if (data.type === "gotify") {
promise = gotifyMutation.mutateAsync({
appBuildError: appBuildError,
@@ -981,6 +1040,96 @@ export const HandleNotifications = ({ notificationId }: Props) => {
</>
)}
{type === "resend" && (
<>
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>API Key</FormLabel>
<FormControl>
<Input
type="password"
placeholder="re_********"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fromAddress"
render={({ field }) => (
<FormItem>
<FormLabel>From Address</FormLabel>
<FormControl>
<Input placeholder="from@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col gap-2 pt-2">
<FormLabel>To Addresses</FormLabel>
{fields.map((field, index) => (
<div
key={field.id}
className="flex flex-row gap-2 w-full"
>
<FormField
control={form.control}
name={`toAddresses.${index}`}
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<Input
placeholder="email@example.com"
className="w-full"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="outline"
type="button"
onClick={() => {
remove(index);
}}
>
Remove
</Button>
</div>
))}
{type === "resend" &&
"toAddresses" in form.formState.errors && (
<div className="text-sm font-medium text-destructive">
{form.formState?.errors?.toAddresses?.root?.message}
</div>
)}
</div>
<Button
variant="outline"
type="button"
onClick={() => {
append("");
}}
>
Add
</Button>
</>
)}
{type === "gotify" && (
<>
<FormField
@@ -1425,6 +1574,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingTelegram ||
isLoadingDiscord ||
isLoadingEmail ||
isLoadingResend ||
isLoadingGotify ||
isLoadingNtfy ||
isLoadingLark ||
@@ -1464,6 +1614,12 @@ export const HandleNotifications = ({ notificationId }: Props) => {
fromAddress: data.fromAddress,
toAddresses: data.toAddresses,
});
} else if (data.type === "resend") {
await testResendConnection({
apiKey: data.apiKey,
fromAddress: data.fromAddress,
toAddresses: data.toAddresses,
});
} else if (data.type === "gotify") {
await testGotifyConnection({
serverUrl: data.serverUrl,

View File

@@ -5,6 +5,7 @@ import {
GotifyIcon,
LarkIcon,
NtfyIcon,
ResendIcon,
SlackIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
@@ -36,7 +37,7 @@ export const ShowNotifications = () => {
</CardTitle>
<CardDescription>
Add your providers to receive notifications, like Discord, Slack,
Telegram, Email, Lark.
Telegram, Email, Resend, Lark.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
@@ -86,6 +87,11 @@ export const ShowNotifications = () => {
<Mail className="size-6 text-muted-foreground" />
</div>
)}
{notification.notificationType === "resend" && (
<div className="flex items-center justify-center rounded-lg ">
<ResendIcon className="size-6 text-muted-foreground" />
</div>
)}
{notification.notificationType === "gotify" && (
<div className="flex items-center justify-center rounded-lg ">
<GotifyIcon className="size-6" />

View File

@@ -231,3 +231,23 @@ export const NtfyIcon = ({ className }: Props) => {
</svg>
);
};
export const ResendIcon = ({ className }: Props) => {
return (
<svg
viewBox="0 0 24 24"
className={cn("size-8", className)}
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.12" />
<path
d="M8 17V7h6a3 3 0 0 1 0 6H8m6 0 2 4"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</svg>
);
};

View File

@@ -0,0 +1,10 @@
ALTER TYPE "public"."notificationType" ADD VALUE 'resend' BEFORE 'gotify';--> statement-breakpoint
CREATE TABLE "resend" (
"resendId" text PRIMARY KEY NOT NULL,
"apiKey" text NOT NULL,
"fromAddress" text NOT NULL,
"toAddress" text[] NOT NULL
);
--> statement-breakpoint
ALTER TABLE "notification" ADD COLUMN "resendId" text;--> statement-breakpoint
ALTER TABLE "notification" ADD CONSTRAINT "notification_resendId_resend_resendId_fk" FOREIGN KEY ("resendId") REFERENCES "public"."resend"("resendId") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -946,6 +946,13 @@
"when": 1767871040249,
"tag": "0134_strong_hercules",
"breakpoints": true
},
{
"idx": 135,
"version": "7",
"when": 1769277372852,
"tag": "0135_breezy_slapstick",
"breakpoints": true
}
]
}

View File

@@ -5,6 +5,7 @@ import {
createGotifyNotification,
createLarkNotification,
createNtfyNotification,
createResendNotification,
createSlackNotification,
createTelegramNotification,
findNotificationById,
@@ -17,6 +18,7 @@ import {
sendGotifyNotification,
sendLarkNotification,
sendNtfyNotification,
sendResendNotification,
sendServerThresholdNotifications,
sendSlackNotification,
sendTelegramNotification,
@@ -26,6 +28,7 @@ import {
updateGotifyNotification,
updateLarkNotification,
updateNtfyNotification,
updateResendNotification,
updateSlackNotification,
updateTelegramNotification,
} from "@dokploy/server";
@@ -46,6 +49,7 @@ import {
apiCreateGotify,
apiCreateLark,
apiCreateNtfy,
apiCreateResend,
apiCreateSlack,
apiCreateTelegram,
apiFindOneNotification,
@@ -55,6 +59,7 @@ import {
apiTestGotifyConnection,
apiTestLarkConnection,
apiTestNtfyConnection,
apiTestResendConnection,
apiTestSlackConnection,
apiTestTelegramConnection,
apiUpdateCustom,
@@ -63,6 +68,7 @@ import {
apiUpdateGotify,
apiUpdateLark,
apiUpdateNtfy,
apiUpdateResend,
apiUpdateSlack,
apiUpdateTelegram,
notifications,
@@ -296,6 +302,63 @@ export const notificationRouter = createTRPCRouter({
});
}
}),
createResend: adminProcedure
.input(apiCreateResend)
.mutation(async ({ input, ctx }) => {
try {
return await createResendNotification(
input,
ctx.session.activeOrganizationId,
);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the notification",
cause: error,
});
}
}),
updateResend: adminProcedure
.input(apiUpdateResend)
.mutation(async ({ input, ctx }) => {
try {
const notification = await findNotificationById(input.notificationId);
if (notification.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this notification",
});
}
return await updateResendNotification({
...input,
organizationId: ctx.session.activeOrganizationId,
});
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error updating the notification",
cause: error,
});
}
}),
testResendConnection: adminProcedure
.input(apiTestResendConnection)
.mutation(async ({ input }) => {
try {
await sendResendNotification(
input,
"Test Email",
"<p>Hi, From Dokploy 👋</p>",
);
return true;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `${error instanceof Error ? error.message : "Unknown error"}`,
cause: error,
});
}
}),
remove: adminProcedure
.input(apiFindOneNotification)
.mutation(async ({ input, ctx }) => {
@@ -338,6 +401,7 @@ export const notificationRouter = createTRPCRouter({
telegram: true,
discord: true,
email: true,
resend: true,
gotify: true,
ntfy: true,
custom: true,
@@ -639,6 +703,7 @@ export const notificationRouter = createTRPCRouter({
where: eq(notifications.organizationId, ctx.session.activeOrganizationId),
with: {
email: true,
resend: true,
},
});
}),

View File

@@ -9,6 +9,7 @@ import {
IS_CLOUD,
removeUserById,
sendEmailNotification,
sendResendNotification,
updateUser,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
@@ -509,15 +510,16 @@ export const userRouter = createTRPCRouter({
const notification = await findNotificationById(input.notificationId);
const email = notification.email;
const resend = notification.resend;
const currentInvitation = await db.query.invitation.findFirst({
where: eq(invitation.id, input.invitationId),
});
if (!email) {
if (!email && !resend) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Email notification not found",
message: "Email provider not found",
});
}
@@ -532,16 +534,29 @@ export const userRouter = createTRPCRouter({
);
try {
await sendEmailNotification(
{
...email,
toAddresses: [currentInvitation?.email || ""],
},
"Invitation to join organization",
`
<p>You are invited to join ${organization?.name || "organization"} on Dokploy. Click the link to accept the invitation: <a href="${inviteLink}">Accept Invitation</a></p>
`,
);
const htmlContent = `
\t\t\t\t<p>You are invited to join ${organization?.name || "organization"} on Dokploy. Click the link to accept the invitation: <a href="${inviteLink}">Accept Invitation</a></p>
\t\t\t\t`;
if (email) {
await sendEmailNotification(
{
...email,
toAddresses: [currentInvitation?.email || ""],
},
"Invitation to join organization",
htmlContent,
);
} else if (resend) {
await sendResendNotification(
{
...resend,
toAddresses: [currentInvitation?.email || ""],
},
"Invitation to join organization",
htmlContent,
);
}
} catch (error) {
console.log(error);
throw error;