feat: implement invitation email functionality for organization creation

- Added `sendInvitationEmail` function to send invitation emails when a new organization is created in the cloud environment.
- Updated email template to enhance the invitation message and included a direct link for users to accept the invitation.
- Refactored email sending logic in the user router to utilize the new invitation email rendering function.
- Improved organization invitation email design for better user experience.
This commit is contained in:
Mauricio Siu
2026-04-24 21:40:08 -06:00
parent cdd77a04dc
commit b610f7aeff
6 changed files with 132 additions and 80 deletions

View File

@@ -1,5 +1,5 @@
import { db } from "@dokploy/server/db";
import { IS_CLOUD } from "@dokploy/server/index";
import { IS_CLOUD, sendInvitationEmail } from "@dokploy/server/index";
import { TRPCError } from "@trpc/server";
import { and, desc, eq, exists } from "drizzle-orm";
import { nanoid } from "nanoid";
@@ -325,6 +325,24 @@ export const organizationRouter = createTRPCRouter({
})
.returning();
if (IS_CLOUD && created) {
const host =
process.env.NODE_ENV === "development"
? "http://localhost:3000"
: "https://app.dokploy.com";
const inviteLink = `${host}/invitation?token=${created.id}`;
const org = await db.query.organization.findFirst({
where: eq(organization.id, orgId),
});
await sendInvitationEmail({
email,
inviteLink,
organizationName: org?.name || "organization",
});
}
await audit(ctx, {
action: "create",
resourceType: "organization",

View File

@@ -9,12 +9,12 @@ import {
getWebServerSettings,
IS_CLOUD,
removeUserById,
renderInvitationEmail,
sendEmailNotification,
sendResendNotification,
updateUser,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import {
account,
apiAssignPermissions,
@@ -29,6 +29,7 @@ import {
hasPermission,
resolvePermissions,
} from "@dokploy/server/services/permission";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { TRPCError } from "@trpc/server";
import * as bcrypt from "bcrypt";
import { and, asc, eq, gt } from "drizzle-orm";
@@ -639,27 +640,26 @@ export const userRouter = createTRPCRouter({
);
try {
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`;
const toEmail = currentInvitation?.email || "";
const orgName = organization?.name || "organization";
const subject = `You've been invited to join ${orgName} on Dokploy`;
const html = await renderInvitationEmail({
email: toEmail,
inviteLink,
organizationName: orgName,
});
if (email) {
await sendEmailNotification(
{
...email,
toAddresses: [currentInvitation?.email || ""],
},
"Invitation to join organization",
htmlContent,
{ ...email, toAddresses: [toEmail] },
subject,
html,
);
} else if (resend) {
await sendResendNotification(
{
...resend,
toAddresses: [currentInvitation?.email || ""],
},
"Invitation to join organization",
htmlContent,
{ ...resend, toAddresses: [toEmail] },
subject,
html,
);
}
} catch (error) {

View File

@@ -14,21 +14,18 @@ import {
Text,
} from "@react-email/components";
export type TemplateProps = {
email: string;
name: string;
};
interface VercelInviteUserEmailProps {
interface InvitationEmailProps {
inviteLink: string;
toEmail: string;
organizationName: string;
}
export const InvitationEmail = ({
inviteLink,
toEmail,
}: VercelInviteUserEmailProps) => {
const previewText = "Join to Dokploy";
organizationName = "an organization",
}: InvitationEmailProps) => {
const previewText = `You've been invited to join ${organizationName} on Dokploy`;
return (
<Html>
<Head />
@@ -44,50 +41,67 @@ export const InvitationEmail = ({
},
}}
>
<Body className="bg-white my-auto mx-auto font-sans px-2">
<Container className="border border-solid border-[#eaeaea] rounded-lg my-[40px] mx-auto p-[20px] max-w-[465px]">
<Section className="mt-[32px]">
<Body className="bg-[#f4f4f5] my-auto mx-auto font-sans">
<Container className="my-[40px] mx-auto max-w-[520px]">
{/* Header */}
<Section className="bg-[#09090b] rounded-t-xl px-[40px] py-[32px] text-center">
<Img
src={
"https://raw.githubusercontent.com/Dokploy/dokploy/refs/heads/canary/apps/dokploy/logo.png"
}
width="100"
height="50"
src="https://raw.githubusercontent.com/Dokploy/website/refs/heads/main/apps/docs/public/logo-dokploy-blackpng.png"
width="190"
height="120"
alt="Dokploy"
className="my-0 mx-auto"
/>
</Section>
<Heading className="text-black text-[24px] font-normal text-center p-0 my-[30px] mx-0">
Join to <strong>Dokploy</strong>
</Heading>
<Text className="text-black text-[14px] leading-[24px]">
Hello,
</Text>
<Text className="text-black text-[14px] leading-[24px]">
You have been invited to join <strong>Dokploy</strong>, a platform
that helps for deploying your apps to the cloud.
</Text>
<Section className="text-center mt-[32px] mb-[32px]">
<Button
href={inviteLink}
className="bg-[#000000] rounded text-white text-[12px] font-semibold no-underline text-center px-5 py-3"
>
Join the team 🚀
</Button>
{/* Body */}
<Section className="bg-white px-[40px] py-[32px]">
<Heading className="text-[#09090b] text-[22px] font-semibold m-0 mb-[8px]">
You've been invited to join {organizationName}
</Heading>
<Text className="text-[#71717a] text-[14px] leading-[22px] m-0 mb-[24px]">
You have been invited to join{" "}
<strong className="text-[#09090b]">{organizationName}</strong>{" "}
on Dokploy, the platform for deploying your apps to the cloud.
Click the button below to accept the invitation.
</Text>
{/* CTA Button */}
<Section className="text-center mb-[24px]">
<Button
href={inviteLink}
className="bg-[#09090b] rounded-lg text-white text-[14px] font-semibold no-underline text-center px-[24px] py-[12px]"
>
Accept Invitation
</Button>
</Section>
<Text className="text-[#a1a1aa] text-[13px] leading-[20px] m-0 text-center mb-[16px]">
If the button above doesn't work, copy and paste the following
link into your browser:
</Text>
<Text className="text-[#71717a] text-[12px] leading-[18px] m-0 text-center break-all">
{inviteLink}
</Text>
</Section>
{/* Footer */}
<Section className="bg-[#fafafa] rounded-b-xl px-[40px] py-[24px] text-center border-t border-solid border-[#e4e4e7]">
<Hr className="border border-solid border-[#e4e4e7] my-0 mb-[16px] mx-0 w-full" />
<Text className="text-[#a1a1aa] text-[12px] leading-[18px] m-0">
This invitation was intended for{" "}
<span className="text-[#71717a]">{toEmail}</span>. This invite
was sent from{" "}
<Link
href="https://dokploy.com"
className="text-[#71717a] underline"
>
Dokploy Cloud
</Link>
. If you were not expecting this invitation, you can safely
ignore this email.
</Text>
</Section>
<Text className="text-black text-[14px] leading-[24px]">
or copy and paste this URL into your browser:{" "}
<Link href={inviteLink} className="text-blue-600 no-underline">
https://dokploy.com
</Link>
</Text>
<Hr className="border border-solid border-[#eaeaea] my-[26px] mx-0 w-full" />
<Text className="text-[#666666] text-[12px] leading-[24px]">
This invitation was intended for {toEmail}. This invite was sent
from <strong className="text-black">dokploy.com</strong>. If you
were not expecting this invitation, you can ignore this email. If
you are concerned about your account's safety, please reply to
</Text>
</Container>
</Body>
</Tailwind>

View File

@@ -108,6 +108,7 @@ export * from "./utils/notifications/docker-cleanup";
export * from "./utils/notifications/dokploy-restart";
export * from "./utils/notifications/server-threshold";
export * from "./utils/notifications/utils";
export * from "./verification/send-verification-email";
export * from "./utils/process/execAsync";
export * from "./utils/process/spawnAsync";
export * from "./utils/providers/bitbucket";

View File

@@ -409,23 +409,6 @@ const { handler, api } = betterAuth({
enabled: true,
maximumRolesPerOrganization: 10,
},
async sendInvitationEmail(data, _request) {
if (IS_CLOUD) {
const host =
process.env.NODE_ENV === "development"
? "http://localhost:3000"
: "https://app.dokploy.com";
const inviteLink = `${host}/invitation?token=${data.id}`;
await sendEmail({
email: data.email,
subject: "Invitation to join organization",
text: `
<p>You are invited to join ${data.organization.name} on Dokploy. Click the link to accept the invitation: <a href="${inviteLink}">Accept Invitation</a></p>
`,
});
}
},
}),
...(IS_CLOUD
? [

View File

@@ -1,4 +1,5 @@
import { renderAsync } from "@react-email/components";
import InvitationEmail from "../emails/emails/invitation";
import VerifyEmailTemplate from "../emails/emails/verify-email";
import { sendEmailNotification } from "../utils/notifications/utils";
@@ -51,3 +52,38 @@ export const sendVerificationEmail = async ({
text: html,
});
};
export const renderInvitationEmail = async ({
email,
inviteLink,
organizationName,
}: {
email: string;
inviteLink: string;
organizationName: string;
}) => {
return renderAsync(
InvitationEmail({
inviteLink,
toEmail: email,
organizationName,
}),
);
};
export const sendInvitationEmail = async ({
email,
inviteLink,
organizationName,
}: {
email: string;
inviteLink: string;
organizationName: string;
}) => {
const html = await renderInvitationEmail({ email, inviteLink, organizationName });
await sendEmail({
email,
subject: `You've been invited to join ${organizationName} on Dokploy`,
text: html,
});
};