Merge pull request #3512 from mhbdev/resend-provider-for-notifications

feat: add resend notification functionality
This commit is contained in:
Mauricio Siu
2026-02-05 15:01:10 -06:00
committed by GitHub
22 changed files with 7880 additions and 50 deletions

View File

@@ -16,6 +16,7 @@ import {
LarkIcon,
NtfyIcon,
PushoverIcon,
ResendIcon,
SlackIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
@@ -97,6 +98,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"),
@@ -169,6 +187,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",
@@ -214,6 +236,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 } =
@@ -242,6 +266,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();
@@ -281,7 +308,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]);
@@ -349,6 +376,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,
@@ -442,6 +484,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
telegram: telegramMutation,
discord: discordMutation,
email: emailMutation,
resend: resendMutation,
gotify: gotifyMutation,
ntfy: ntfyMutation,
lark: larkMutation,
@@ -525,6 +568,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,
@@ -1042,6 +1101,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
@@ -1627,6 +1776,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingTelegram ||
isLoadingDiscord ||
isLoadingEmail ||
isLoadingResend ||
isLoadingGotify ||
isLoadingNtfy ||
isLoadingLark ||
@@ -1667,6 +1817,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

@@ -257,3 +257,23 @@ export const PushoverIcon = ({ 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

@@ -967,6 +967,13 @@
"when": 1770274109332,
"tag": "0137_colossal_sally_floyd",
"breakpoints": true
},
{
"idx": 138,
"version": "7",
"when": 1770324882572,
"tag": "0138_pretty_ironclad",
"breakpoints": true
}
]
}

View File

@@ -5,7 +5,6 @@ import {
findDomainById,
findDomainsByApplicationId,
findDomainsByComposeId,
findOrganizationById,
findPreviewDeploymentById,
findServerById,
generateTraefikMeDomain,

View File

@@ -6,6 +6,7 @@ import {
createLarkNotification,
createNtfyNotification,
createPushoverNotification,
createResendNotification,
createSlackNotification,
createTelegramNotification,
findNotificationById,
@@ -19,6 +20,7 @@ import {
sendLarkNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
sendServerThresholdNotifications,
sendSlackNotification,
sendTelegramNotification,
@@ -29,6 +31,7 @@ import {
updateLarkNotification,
updateNtfyNotification,
updatePushoverNotification,
updateResendNotification,
updateSlackNotification,
updateTelegramNotification,
} from "@dokploy/server";
@@ -50,6 +53,7 @@ import {
apiCreateLark,
apiCreateNtfy,
apiCreatePushover,
apiCreateResend,
apiCreateSlack,
apiCreateTelegram,
apiFindOneNotification,
@@ -60,6 +64,7 @@ import {
apiTestLarkConnection,
apiTestNtfyConnection,
apiTestPushoverConnection,
apiTestResendConnection,
apiTestSlackConnection,
apiTestTelegramConnection,
apiUpdateCustom,
@@ -69,6 +74,7 @@ import {
apiUpdateLark,
apiUpdateNtfy,
apiUpdatePushover,
apiUpdateResend,
apiUpdateSlack,
apiUpdateTelegram,
notifications,
@@ -302,6 +308,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 }) => {
@@ -344,6 +407,7 @@ export const notificationRouter = createTRPCRouter({
telegram: true,
discord: true,
email: true,
resend: true,
gotify: true,
ntfy: true,
custom: true,
@@ -702,6 +766,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;

View File

@@ -75,6 +75,7 @@
"qrcode": "^1.5.4",
"react": "18.2.0",
"react-dom": "18.2.0",
"resend": "^6.0.2",
"shell-quote": "^1.8.1",
"slugify": "^1.6.6",
"ssh2": "1.15.0",

View File

@@ -69,6 +69,7 @@ enum notificationType {
telegram
discord
email
resend
gotify
ntfy
custom
@@ -456,6 +457,13 @@ table email {
toAddress text[] [not null]
}
table resend {
resendId text [pk, not null]
apiKey text [not null]
fromAddress text [not null]
toAddress text[] [not null]
}
table environment {
environmentId text [pk, not null]
name text [not null]
@@ -695,6 +703,7 @@ table notification {
telegramId text
discordId text
emailId text
resendId text
gotifyId text
ntfyId text
customId text
@@ -1139,6 +1148,8 @@ ref: notification.discordId - discord.discordId
ref: notification.emailId - email.emailId
ref: notification.resendId - resend.resendId
ref: notification.gotifyId - gotify.gotifyId
ref: notification.ntfyId - ntfy.ntfyId
@@ -1197,4 +1208,4 @@ ref: volume_backup.redisId - redis.redisId
ref: volume_backup.composeId - compose.composeId
ref: volume_backup.destinationId - destination.destinationId
ref: volume_backup.destinationId - destination.destinationId

View File

@@ -17,6 +17,7 @@ export const notificationType = pgEnum("notificationType", [
"telegram",
"discord",
"email",
"resend",
"gotify",
"ntfy",
"pushover",
@@ -53,6 +54,9 @@ export const notifications = pgTable("notification", {
emailId: text("emailId").references(() => email.emailId, {
onDelete: "cascade",
}),
resendId: text("resendId").references(() => resend.resendId, {
onDelete: "cascade",
}),
gotifyId: text("gotifyId").references(() => gotify.gotifyId, {
onDelete: "cascade",
}),
@@ -114,6 +118,16 @@ export const email = pgTable("email", {
toAddresses: text("toAddress").array().notNull(),
});
export const resend = pgTable("resend", {
resendId: text("resendId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
apiKey: text("apiKey").notNull(),
fromAddress: text("fromAddress").notNull(),
toAddresses: text("toAddress").array().notNull(),
});
export const gotify = pgTable("gotify", {
gotifyId: text("gotifyId")
.notNull()
@@ -182,6 +196,10 @@ export const notificationsRelations = relations(notifications, ({ one }) => ({
fields: [notifications.emailId],
references: [email.emailId],
}),
resend: one(resend, {
fields: [notifications.resendId],
references: [resend.resendId],
}),
gotify: one(gotify, {
fields: [notifications.gotifyId],
references: [gotify.gotifyId],
@@ -335,6 +353,36 @@ export const apiTestEmailConnection = apiCreateEmail.pick({
fromAddress: true,
});
export const apiCreateResend = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
appDeploy: true,
dockerCleanup: true,
serverThreshold: true,
})
.extend({
apiKey: z.string().min(1),
fromAddress: z.string().min(1),
toAddresses: z.array(z.string()).min(1),
})
.required();
export const apiUpdateResend = apiCreateResend.partial().extend({
notificationId: z.string().min(1),
resendId: z.string().min(1),
organizationId: z.string().optional(),
});
export const apiTestResendConnection = apiCreateResend.pick({
apiKey: true,
fromAddress: true,
toAddresses: true,
});
export const apiCreateGotify = notificationsSchema
.pick({
appBuildError: true,
@@ -534,6 +582,7 @@ export const apiSendTest = notificationsSchema
username: z.string(),
password: z.string(),
toAddresses: z.array(z.string()),
apiKey: z.string(),
serverUrl: z.string(),
topic: z.string(),
appToken: z.string(),

View File

@@ -7,6 +7,7 @@ import {
type apiCreateLark,
type apiCreateNtfy,
type apiCreatePushover,
type apiCreateResend,
type apiCreateSlack,
type apiCreateTelegram,
type apiUpdateCustom,
@@ -16,6 +17,7 @@ import {
type apiUpdateLark,
type apiUpdateNtfy,
type apiUpdatePushover,
type apiUpdateResend,
type apiUpdateSlack,
type apiUpdateTelegram,
custom,
@@ -26,6 +28,7 @@ import {
notifications,
ntfy,
pushover,
resend,
slack,
telegram,
} from "@dokploy/server/db/schema";
@@ -412,6 +415,100 @@ export const updateEmailNotification = async (
});
};
export const createResendNotification = async (
input: typeof apiCreateResend._type,
organizationId: string,
) => {
await db.transaction(async (tx) => {
const newResend = await tx
.insert(resend)
.values({
apiKey: input.apiKey,
fromAddress: input.fromAddress,
toAddresses: input.toAddresses,
})
.returning()
.then((value) => value[0]);
if (!newResend) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting resend",
});
}
const newDestination = await tx
.insert(notifications)
.values({
resendId: newResend.resendId,
name: input.name,
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
notificationType: "resend",
organizationId: organizationId,
serverThreshold: input.serverThreshold,
})
.returning()
.then((value) => value[0]);
if (!newDestination) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting notification",
});
}
return newDestination;
});
};
export const updateResendNotification = async (
input: typeof apiUpdateResend._type,
) => {
await db.transaction(async (tx) => {
const newDestination = await tx
.update(notifications)
.set({
name: input.name,
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
organizationId: input.organizationId,
serverThreshold: input.serverThreshold,
})
.where(eq(notifications.notificationId, input.notificationId))
.returning()
.then((value) => value[0]);
if (!newDestination) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error Updating notification",
});
}
await tx
.update(resend)
.set({
apiKey: input.apiKey,
fromAddress: input.fromAddress,
toAddresses: input.toAddresses,
})
.where(eq(resend.resendId, input.resendId))
.returning()
.then((value) => value[0]);
return newDestination;
});
};
export const createGotifyNotification = async (
input: typeof apiCreateGotify._type,
organizationId: string,
@@ -693,6 +790,7 @@ export const findNotificationById = async (notificationId: string) => {
telegram: true,
discord: true,
email: true,
resend: true,
gotify: true,
ntfy: true,
custom: true,

View File

@@ -12,6 +12,7 @@ import {
sendLarkNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTelegramNotification,
} from "./utils";
@@ -45,6 +46,7 @@ export const sendBuildErrorNotifications = async ({
discord: true,
telegram: true,
slack: true,
resend: true,
gotify: true,
ntfy: true,
custom: true,
@@ -56,6 +58,7 @@ export const sendBuildErrorNotifications = async ({
for (const notification of notificationList) {
const {
email,
resend,
discord,
telegram,
slack,
@@ -66,7 +69,7 @@ export const sendBuildErrorNotifications = async ({
pushover,
} = notification;
try {
if (email) {
if (email || resend) {
const template = await renderAsync(
BuildFailedEmail({
projectName,
@@ -77,11 +80,22 @@ export const sendBuildErrorNotifications = async ({
date: date.toLocaleString(),
}),
).catch();
await sendEmailNotification(
email,
"Build failed for dokploy",
template,
);
if (email) {
await sendEmailNotification(
email,
"Build failed for dokploy",
template,
);
}
if (resend) {
await sendResendNotification(
resend,
"Build failed for dokploy",
template,
);
}
}
if (discord) {

View File

@@ -13,6 +13,7 @@ import {
sendLarkNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTelegramNotification,
} from "./utils";
@@ -48,6 +49,7 @@ export const sendBuildSuccessNotifications = async ({
discord: true,
telegram: true,
slack: true,
resend: true,
gotify: true,
ntfy: true,
custom: true,
@@ -59,6 +61,7 @@ export const sendBuildSuccessNotifications = async ({
for (const notification of notificationList) {
const {
email,
resend,
discord,
telegram,
slack,
@@ -69,7 +72,7 @@ export const sendBuildSuccessNotifications = async ({
pushover,
} = notification;
try {
if (email) {
if (email || resend) {
const template = await renderAsync(
BuildSuccessEmail({
projectName,
@@ -80,11 +83,22 @@ export const sendBuildSuccessNotifications = async ({
environmentName,
}),
).catch();
await sendEmailNotification(
email,
"Build success for dokploy",
template,
);
if (email) {
await sendEmailNotification(
email,
"Build success for dokploy",
template,
);
}
if (resend) {
await sendResendNotification(
resend,
"Build success for dokploy",
template,
);
}
}
if (discord) {

View File

@@ -12,6 +12,7 @@ import {
sendLarkNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTelegramNotification,
} from "./utils";
@@ -45,6 +46,7 @@ export const sendDatabaseBackupNotifications = async ({
discord: true,
telegram: true,
slack: true,
resend: true,
gotify: true,
ntfy: true,
custom: true,
@@ -56,6 +58,7 @@ export const sendDatabaseBackupNotifications = async ({
for (const notification of notificationList) {
const {
email,
resend,
discord,
telegram,
slack,
@@ -66,7 +69,7 @@ export const sendDatabaseBackupNotifications = async ({
pushover,
} = notification;
try {
if (email) {
if (email || resend) {
const template = await renderAsync(
DatabaseBackupEmail({
projectName,
@@ -77,11 +80,22 @@ export const sendDatabaseBackupNotifications = async ({
date: date.toLocaleString(),
}),
).catch();
await sendEmailNotification(
email,
"Database backup for dokploy",
template,
);
if (email) {
await sendEmailNotification(
email,
"Database backup for dokploy",
template,
);
}
if (resend) {
await sendResendNotification(
resend,
"Database backup for dokploy",
template,
);
}
}
if (discord) {

View File

@@ -12,6 +12,7 @@ import {
sendLarkNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTelegramNotification,
} from "./utils";
@@ -32,6 +33,7 @@ export const sendDockerCleanupNotifications = async (
discord: true,
telegram: true,
slack: true,
resend: true,
gotify: true,
ntfy: true,
custom: true,
@@ -43,6 +45,7 @@ export const sendDockerCleanupNotifications = async (
for (const notification of notificationList) {
const {
email,
resend,
discord,
telegram,
slack,
@@ -53,16 +56,26 @@ export const sendDockerCleanupNotifications = async (
pushover,
} = notification;
try {
if (email) {
if (email || resend) {
const template = await renderAsync(
DockerCleanupEmail({ message, date: date.toLocaleString() }),
).catch();
await sendEmailNotification(
email,
"Docker cleanup for dokploy",
template,
);
if (email) {
await sendEmailNotification(
email,
"Docker cleanup for dokploy",
template,
);
}
if (resend) {
await sendResendNotification(
resend,
"Docker cleanup for dokploy",
template,
);
}
}
if (discord) {

View File

@@ -12,6 +12,7 @@ import {
sendLarkNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTelegramNotification,
} from "./utils";
@@ -26,6 +27,7 @@ export const sendDokployRestartNotifications = async () => {
discord: true,
telegram: true,
slack: true,
resend: true,
gotify: true,
ntfy: true,
custom: true,
@@ -37,6 +39,7 @@ export const sendDokployRestartNotifications = async () => {
for (const notification of notificationList) {
const {
email,
resend,
discord,
telegram,
slack,
@@ -48,16 +51,26 @@ export const sendDokployRestartNotifications = async () => {
} = notification;
try {
if (email) {
if (email || resend) {
const template = await renderAsync(
DokployRestartEmail({ date: date.toLocaleString() }),
).catch();
await sendEmailNotification(
email,
"Dokploy Server Restarted",
template,
);
if (email) {
await sendEmailNotification(
email,
"Dokploy Server Restarted",
template,
);
}
if (resend) {
await sendResendNotification(
resend,
"Dokploy Server Restarted",
template,
);
}
}
if (discord) {

View File

@@ -6,10 +6,12 @@ import type {
lark,
ntfy,
pushover,
resend,
slack,
telegram,
} from "@dokploy/server/db/schema";
import nodemailer from "nodemailer";
import { Resend } from "resend";
export const sendEmailNotification = async (
connection: typeof email.$inferInsert,
@@ -46,6 +48,32 @@ export const sendEmailNotification = async (
}
};
export const sendResendNotification = async (
connection: typeof resend.$inferInsert,
subject: string,
htmlContent: string,
) => {
try {
const client = new Resend(connection.apiKey);
const result = await client.emails.send({
from: connection.fromAddress,
to: connection.toAddresses,
subject,
html: htmlContent,
});
if (result.error) {
throw new Error(result.error.message);
}
} catch (err) {
console.log(err);
throw new Error(
`Failed to send Resend notification ${err instanceof Error ? err.message : "Unknown error"}`,
);
}
};
export const sendDiscordNotification = async (
connection: typeof discord.$inferInsert,
embed: any,

View File

@@ -10,6 +10,7 @@ import {
sendGotifyNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTelegramNotification,
} from "./utils";
@@ -52,6 +53,7 @@ export const sendVolumeBackupNotifications = async ({
discord: true,
telegram: true,
slack: true,
resend: true,
gotify: true,
ntfy: true,
pushover: true,
@@ -59,10 +61,10 @@ export const sendVolumeBackupNotifications = async ({
});
for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify, ntfy, pushover } =
const { email, resend, discord, telegram, slack, gotify, ntfy, pushover } =
notification;
if (email) {
if (email || resend) {
const subject = `Volume Backup ${type === "success" ? "Successful" : "Failed"} - ${applicationName}`;
const htmlContent = await renderAsync(
VolumeBackupEmail({
@@ -76,7 +78,12 @@ export const sendVolumeBackupNotifications = async ({
date: date.toISOString(),
}),
);
await sendEmailNotification(email, subject, htmlContent);
if (email) {
await sendEmailNotification(email, subject, htmlContent);
}
if (resend) {
await sendResendNotification(resend, subject, htmlContent);
}
}
if (discord) {

50
pnpm-lock.yaml generated
View File

@@ -717,6 +717,9 @@ importers:
react-dom:
specifier: 18.2.0
version: 18.2.0(react@18.2.0)
resend:
specifier: ^6.0.2
version: 6.8.0(@react-email/render@0.0.16(react-dom@18.2.0(react@18.2.0))(react@18.2.0))
semver:
specifier: 7.7.3
version: 7.7.3
@@ -4030,6 +4033,9 @@ packages:
resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==}
engines: {node: '>=14.16'}
'@stablelib/base64@1.0.1':
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
'@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
@@ -5574,6 +5580,9 @@ packages:
fast-safe-stringify@2.1.1:
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
fast-sha256@1.3.0:
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
fast-xml-parser@5.3.3:
resolution: {integrity: sha512-2O3dkPAAC6JavuMm8+4+pgTk+5hoAs+CjZ+sWcQLkX9+/tHRuTkQh/Oaifr8qDmZ8iEHb771Ea6G8CdwkrgvYA==}
hasBin: true
@@ -7378,6 +7387,15 @@ packages:
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
resend@6.8.0:
resolution: {integrity: sha512-fDOXGqafQfQXl8nXe93wr93pus8tW7YPpowenE3SmG7dJJf0hH3xUWm3xqacnPvhqjCQTJH9xETg07rmUeSuqQ==}
engines: {node: '>=20'}
peerDependencies:
'@react-email/render': '*'
peerDependenciesMeta:
'@react-email/render':
optional: true
resolve-alpn@1.2.1:
resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
@@ -7606,6 +7624,9 @@ packages:
standard-as-callback@2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
standardwebhooks@1.0.0:
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
statuses@2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
@@ -7705,6 +7726,9 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
svix@1.84.1:
resolution: {integrity: sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==}
swagger-client@3.35.3:
resolution: {integrity: sha512-4bO+dhBbasP485Ak67o46cWNVUnV0/92ypb2997bhvxTO2M+IuQZM1ilkN/7nSaiGuxDKJhkuL54I35PVI3AAw==}
@@ -7982,6 +8006,10 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
uuid@10.0.0:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
hasBin: true
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
@@ -11495,6 +11523,8 @@ snapshots:
'@sindresorhus/is@5.6.0': {}
'@stablelib/base64@1.0.1': {}
'@standard-schema/spec@1.0.0': {}
'@stepperize/react@4.0.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
@@ -13320,6 +13350,8 @@ snapshots:
fast-safe-stringify@2.1.1: {}
fast-sha256@1.3.0: {}
fast-xml-parser@5.3.3:
dependencies:
strnum: 2.1.2
@@ -15348,6 +15380,12 @@ snapshots:
reselect@5.1.1: {}
resend@6.8.0(@react-email/render@0.0.16(react-dom@18.2.0(react@18.2.0))(react@18.2.0)):
dependencies:
svix: 1.84.1
optionalDependencies:
'@react-email/render': 0.0.16(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
resolve-alpn@1.2.1: {}
resolve-pkg-maps@1.0.0: {}
@@ -15613,6 +15651,11 @@ snapshots:
standard-as-callback@2.1.0: {}
standardwebhooks@1.0.0:
dependencies:
'@stablelib/base64': 1.0.1
fast-sha256: 1.3.0
statuses@2.0.1: {}
std-env@3.10.0: {}
@@ -15708,6 +15751,11 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
svix@1.84.1:
dependencies:
standardwebhooks: 1.0.0
uuid: 10.0.0
swagger-client@3.35.3:
dependencies:
'@babel/runtime-corejs3': 7.27.3
@@ -16053,6 +16101,8 @@ snapshots:
util-deprecate@1.0.2: {}
uuid@10.0.0: {}
uuid@8.3.2: {}
uuid@9.0.1: {}

View File

@@ -69,6 +69,7 @@ enum notificationType {
telegram
discord
email
resend
gotify
ntfy
custom
@@ -455,6 +456,13 @@ table email {
toAddress text[] [not null]
}
table resend {
resendId text [pk, not null]
apiKey text [not null]
fromAddress text [not null]
toAddress text[] [not null]
}
table environment {
environmentId text [pk, not null]
name text [not null]
@@ -691,6 +699,7 @@ table notification {
telegramId text
discordId text
emailId text
resendId text
gotifyId text
ntfyId text
customId text
@@ -1133,6 +1142,8 @@ ref: notification.discordId - discord.discordId
ref: notification.emailId - email.emailId
ref: notification.resendId - resend.resendId
ref: notification.gotifyId - gotify.gotifyId
ref: notification.ntfyId - ntfy.ntfyId
@@ -1191,4 +1202,4 @@ ref: volume_backup.redisId - redis.redisId
ref: volume_backup.composeId - compose.composeId
ref: volume_backup.destinationId - destination.destinationId
ref: volume_backup.destinationId - destination.destinationId